完善开放接口,完善开票历史

This commit is contained in:
BBIT-Kai
2026-05-21 10:53:08 +08:00
parent ccc164b176
commit 40f1c27e71
15 changed files with 1087 additions and 431 deletions
@@ -6,6 +6,7 @@ import com.bbit.ticket.dao.system.pageOffset
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceRedTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
@@ -320,6 +321,8 @@ object BlueInvoiceDao {
}[HistoryInvoiceBasicTable.id] }[HistoryInvoiceBasicTable.id]
} }
syncBlueRedFlag(userId, req.invoiceReqSerialNo, req.code, now)
// 同步商品明细(先删后插) // 同步商品明细(先删后插)
if (!req.itemList.isNullOrEmpty()) { if (!req.itemList.isNullOrEmpty()) {
HistoryInvoiceGoodsTable.deleteWhere { HistoryInvoiceGoodsTable.deleteWhere {
@@ -354,6 +357,38 @@ object BlueInvoiceDao {
} }
} }
private fun syncBlueRedFlag(
userId: Uuid,
redInvoiceReqSerialNo: String,
code: String,
now: OffsetDateTime,
) {
val historyId = HistoryInvoiceRedTable.selectAll()
.where {
(HistoryInvoiceRedTable.userId eq userId) and
(HistoryInvoiceRedTable.invoiceReqSerialNo eq redInvoiceReqSerialNo)
}
.firstOrNull()
?.get(HistoryInvoiceRedTable.historyId)
?: return
HistoryInvoiceBasicTable.update({
(HistoryInvoiceBasicTable.id eq historyId) and
(HistoryInvoiceBasicTable.userId eq userId) and
HistoryInvoiceBasicTable.deletedAt.isNull()
}) {
it[redFlag] = redFlagByCode(code)
it[updatedAt] = now
}
}
private fun redFlagByCode(code: String): String =
when (code) {
"0000" -> "ALREADY_RED"
"9999" -> "RED_FAIL"
else -> "REDING"
}
/** 提取公共字段赋值逻辑(独立函数而非扩展,兼容 insert/update 不同 receiver 类型) */ /** 提取公共字段赋值逻辑(独立函数而非扩展,兼容 insert/update 不同 receiver 类型) */
private fun applySyncFields(it: UpdateBuilder<Int>, req: GetInvoiceInfoResponse) { private fun applySyncFields(it: UpdateBuilder<Int>, req: GetInvoiceInfoResponse) {
// 基础状态 // 基础状态
@@ -414,6 +449,52 @@ object BlueInvoiceDao {
?: throw com.bbit.ticket.entity.common.BizException("NOT_FOUND", "发票记录不存在用户信息") ?: throw com.bbit.ticket.entity.common.BizException("NOT_FOUND", "发票记录不存在用户信息")
} }
fun findRelatedInvoiceReqSerialNos(userId: Uuid, invoiceReqSerialNo: String): List<String> {
val basicRow = HistoryInvoiceBasicTable.selectAll()
.where {
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo) and
HistoryInvoiceBasicTable.deletedAt.isNull()
}
.singleOrNull()
?: return emptyList()
val basicId = basicRow[HistoryInvoiceBasicTable.id]
val invoiceType = basicRow[HistoryInvoiceBasicTable.invoiceType]
val blueHistoryId = if (invoiceType == "2") {
HistoryInvoiceRedTable.selectAll()
.where {
(HistoryInvoiceRedTable.userId eq userId) and
(HistoryInvoiceRedTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}
.singleOrNull()
?.get(HistoryInvoiceRedTable.historyId)
?: return emptyList()
} else {
basicId
}
val blueSerialNo = HistoryInvoiceBasicTable.selectAll()
.where {
(HistoryInvoiceBasicTable.id eq blueHistoryId) and
(HistoryInvoiceBasicTable.userId eq userId) and
HistoryInvoiceBasicTable.deletedAt.isNull()
}
.singleOrNull()
?.get(HistoryInvoiceBasicTable.invoiceReqSerialNo)
val redSerialNos = HistoryInvoiceRedTable.selectAll()
.where {
(HistoryInvoiceRedTable.userId eq userId) and
(HistoryInvoiceRedTable.historyId eq blueHistoryId)
}
.map { it[HistoryInvoiceRedTable.invoiceReqSerialNo] }
return (listOfNotNull(blueSerialNo) + redSerialNos)
.filter { it != invoiceReqSerialNo }
.distinct()
}
fun invoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? { fun invoiceDownloadUrl(userId: Uuid, invoiceReqSerialNo: String): InvoiceDownloadUrlResponse? {
val row = HistoryInvoiceBasicTable.selectAll() val row = HistoryInvoiceBasicTable.selectAll()
.where { .where {
@@ -14,6 +14,7 @@ import kotlin.uuid.Uuid
object HistoryDao { object HistoryDao {
data class HistoryRow( data class HistoryRow(
val invoiceReqSerialNo: String,
val invoiceCode: String?, val invoiceCode: String?,
val invoiceNo: String?, val invoiceNo: String?,
val electronicInvoiceNo: String?, val electronicInvoiceNo: String?,
@@ -62,6 +63,7 @@ object HistoryDao {
?: row[HistoryInvoiceBasicTable.createdAt].format(dateFormatter) ?: row[HistoryInvoiceBasicTable.createdAt].format(dateFormatter)
return HistoryRow( return HistoryRow(
invoiceReqSerialNo = row[HistoryInvoiceBasicTable.invoiceReqSerialNo],
invoiceCode = row[HistoryInvoiceBasicTable.invoiceCode], invoiceCode = row[HistoryInvoiceBasicTable.invoiceCode],
invoiceNo = row[HistoryInvoiceBasicTable.invoiceNo], invoiceNo = row[HistoryInvoiceBasicTable.invoiceNo],
electronicInvoiceNo = row[HistoryInvoiceBasicTable.electronicInvoiceNo], electronicInvoiceNo = row[HistoryInvoiceBasicTable.electronicInvoiceNo],
@@ -12,6 +12,7 @@ import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.math.BigDecimal import java.math.BigDecimal
import java.time.OffsetDateTime import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
@@ -97,6 +98,15 @@ object RedInvoiceDao {
it[HistoryInvoiceRedTable.createdAt] = now it[HistoryInvoiceRedTable.createdAt] = now
it[HistoryInvoiceRedTable.createdBy] = userId it[HistoryInvoiceRedTable.createdBy] = userId
} }
HistoryInvoiceBasicTable.update({
(HistoryInvoiceBasicTable.id eq historyId) and
(HistoryInvoiceBasicTable.userId eq userId) and
HistoryInvoiceBasicTable.deletedAt.isNull()
}) {
it[redFlag] = "REDING"
it[updatedAt] = now
}
} }
/** /**
@@ -8,7 +8,7 @@ import kotlinx.serialization.Serializable
@Serializable @Serializable
data class OpenBlueInvoiceCreateRequest( data class OpenBlueInvoiceCreateRequest(
val requestNo: String? = null, val requestNo: String? = null,
val invoiceReqSerialNo: String, val invoiceReqSerialNo: String? = null,
val invoiceIssueKindCode: String = "82", val invoiceIssueKindCode: String = "82",
val buyerName: String, val buyerName: String,
val purchaseInvSellerIdType: String? = null, val purchaseInvSellerIdType: String? = null,
@@ -2,7 +2,6 @@
package com.bbit.ticket.route.openapi package com.bbit.ticket.route.openapi
import com.bbit.ticket.dao.system.LogDao
import com.bbit.ticket.entity.common.BizException import com.bbit.ticket.entity.common.BizException
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
@@ -10,8 +9,8 @@ import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest
import com.bbit.ticket.service.openapi.OpenBlueInvoiceService import com.bbit.ticket.service.openapi.OpenBlueInvoiceService
import com.bbit.ticket.service.system.ApiAccessLogService
import com.bbit.ticket.utils.requireOpenApiPrincipal import com.bbit.ticket.utils.requireOpenApiPrincipal
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson import com.bbit.ticket.utils.plugins.myJson
import io.ktor.server.application.ApplicationCall import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive import io.ktor.server.request.receive
@@ -19,12 +18,16 @@ import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlin.time.TimeSource import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
/**
* 注册开放平台蓝票开具、查询、票样与批量开票接口。
*
* @receiver 当前 Ktor 路由节点。
*/
fun Route.registerOpenBlueInvoiceRoutes() { fun Route.registerOpenBlueInvoiceRoutes() {
post { post {
val principal = call.requireOpenApiPrincipal() val principal = call.requireOpenApiPrincipal()
@@ -71,6 +74,14 @@ fun Route.registerOpenBlueInvoiceRoutes() {
} }
} }
/**
* 使用开放接口统一响应格式执行接口逻辑,并记录 API 访问日志。
*
* @param appKey 调用方应用密钥。
* @param appName 调用方应用名称。
* @param requestBody 请求体 JSON 文本。
* @param block 当前接口要执行的业务逻辑。
*/
private suspend inline fun <reified T> ApplicationCall.respondOpenApi( private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
appKey: String?, appKey: String?,
appName: String?, appName: String?,
@@ -79,8 +90,7 @@ private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
) { ) {
val start = TimeSource.Monotonic.markNow() val start = TimeSource.Monotonic.markNow()
try { try {
val data = block() val response = ok(block())
val response = ok(data)
val responseBody = myJson.encodeToString(response) val responseBody = myJson.encodeToString(response)
respond(response) respond(response)
saveOpenApiLog(appKey, appName, requestBody, response.code, responseBody, "SUCCESS", null, start) saveOpenApiLog(appKey, appName, requestBody, response.code, responseBody, "SUCCESS", null, start)
@@ -99,6 +109,18 @@ private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
} }
} }
/**
* 保存开放接口访问日志。
*
* @param appKey 调用方应用密钥。
* @param appName 调用方应用名称。
* @param requestBody 请求体 JSON 文本。
* @param responseCode 响应业务状态码。
* @param responseBody 响应体 JSON 文本。
* @param status 日志状态,SUCCESS 或 FAILED。
* @param errorMessage 失败时的错误信息。
* @param start 接口开始时间标记。
*/
private suspend fun ApplicationCall.saveOpenApiLog( private suspend fun ApplicationCall.saveOpenApiLog(
appKey: String?, appKey: String?,
appName: String?, appName: String?,
@@ -108,9 +130,9 @@ private suspend fun ApplicationCall.saveOpenApiLog(
status: String, status: String,
errorMessage: String?, errorMessage: String?,
start: TimeSource.Monotonic.ValueTimeMark, start: TimeSource.Monotonic.ValueTimeMark,
) = dbQuery { ) {
LogDao.saveApiAccessLog( ApiAccessLogService.save(
call = this@saveOpenApiLog, call = this,
appKey = appKey, appKey = appKey,
appName = appName, appName = appName,
requestBody = requestBody, requestBody = requestBody,
@@ -0,0 +1,71 @@
package com.bbit.ticket.route.piaotong
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.application.ApplicationCall
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
/**
* 使用统一票通响应格式执行接口逻辑。
*
* @param fallbackMessage 未知异常时返回给前端的兜底提示。
* @param block 当前接口要执行的业务逻辑。
*/
suspend inline fun <reified T> ApplicationCall.respondPt(
fallbackMessage: String,
crossinline block: suspend () -> T,
) {
try {
respond(ok(block()))
} catch (e: PTException) {
respond(fail(code = e.code, message = e.message, traceId = e.serialNo))
} catch (e: BizException) {
respond(e.status, fail(code = e.errorCode, message = e.message))
} catch (e: Exception) {
respond(fail(code = "-1", message = e.message ?: fallbackMessage))
}
}
/**
* 读取必填查询参数,参数缺失时直接返回失败响应。
*
* @param name 查询参数名称。
* @param message 参数缺失时返回给前端的提示。
*/
suspend fun ApplicationCall.requiredQueryParameter(name: String, message: String): String? {
val value = request.queryParameters[name]?.trim()?.takeIf { it.isNotEmpty() }
if (value == null) {
respond(fail(code = "-1", message = message))
}
return value
}
/**
* 使用统一票通异常处理返回 PDF 预览内容。
*
* @param fallbackMessage 未知异常时返回给前端的兜底提示。
* @param filename 响应头中的文件名。
* @param block 获取 PDF 文件内容的业务逻辑。
*/
suspend fun ApplicationCall.respondPtPdf(
fallbackMessage: String,
filename: String,
block: suspend () -> ByteArray,
) {
try {
response.header(HttpHeaders.ContentDisposition, "inline; filename=\"$filename\"")
respondBytes(block(), ContentType.Application.Pdf)
} catch (e: PTException) {
respond(fail(code = e.code, message = e.message, traceId = e.serialNo))
} catch (e: BizException) {
respond(e.status, fail(code = e.errorCode, message = e.message))
} catch (e: Exception) {
respond(fail(code = "-1", message = e.message ?: fallbackMessage))
}
}
@@ -2,9 +2,7 @@
package com.bbit.ticket.route.piaotong package com.bbit.ticket.route.piaotong
import com.bbit.ticket.entity.common.PTException import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.request.AuthQrcodeRequest import com.bbit.ticket.entity.request.AuthQrcodeRequest
import com.bbit.ticket.entity.request.GetLoginSmsCodeRequest import com.bbit.ticket.entity.request.GetLoginSmsCodeRequest
import com.bbit.ticket.entity.request.QueryRealNameAuthQrStatusRequest import com.bbit.ticket.entity.request.QueryRealNameAuthQrStatusRequest
@@ -19,230 +17,122 @@ 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 com.bbit.ticket.utils.requirePtProfile import com.bbit.ticket.utils.requirePtProfile
import io.ktor.http.HttpStatusCode
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import io.ktor.server.routing.put import io.ktor.server.routing.put
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
/**
* 注册票通认证与本地配置相关接口。
*
* @receiver 当前 Ktor 路由节点。
*/
fun Route.registerPTAuthRoutes() { fun Route.registerPTAuthRoutes() {
get("/info") { get("/info") {
try { call.respondPt("查询票通认证状态失败") {
val currentUser = call.requireCurrentUser() val profile = call.requireCurrentUser().requirePtProfile()
val profile = currentUser.requirePtProfile() PTAuthService.getTaxBureauAccountAuthStatus(
val response = PTAuthService.getTaxBureauAccountAuthStatus(
TaxBureauAuthReq(profile.taxpayerNum, profile.taxAccount) TaxBureauAuthReq(profile.taxpayerNum, profile.taxAccount)
) )
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} }
} }
post("/register") {
try {
val currentUser = call.requireCurrentUser()
val req = call.receive<TaxRegisterInfo>()
val response = PTAuthService.registerEnterprise(req, currentUser.id)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
}
}
post("/registerUser") {
try {
val currentUser = call.requireCurrentUser()
val req = call.receive<TaxRegisterUserRequest>()
val response = PTAuthService.registerUserFromPayload(req, currentUser)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
}
}
// =============================================
// 基础信息配置(本地 CRUD
// =============================================
// 1. 企业信息 post("/register") {
get("/enterprise") { call.respondPt("企业注册失败") {
try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val response = PTConfigService.getEnterpriseInfo(currentUser.id) PTAuthService.registerEnterprise(call.receive<TaxRegisterInfo>(), currentUser.id)
if (response == null) {
call.respond(ok(emptyMap<String, String>()))
} else {
call.respond(ok(response))
} }
} catch (e: Exception) { }
call.respond(fail(code = "-1", message = e.message ?: "查询企业信息失败"))
post("/registerUser") {
call.respondPt("用户注册失败") {
PTAuthService.registerUserFromPayload(
call.receive<TaxRegisterUserRequest>(),
call.requireCurrentUser()
)
}
}
get("/enterprise") {
call.respondPt("查询企业信息失败") {
PTConfigService.getEnterpriseInfo(call.requireCurrentUser().id) ?: emptyMap<String, String>()
} }
} }
put("/enterprise") { put("/enterprise") {
try { call.respondPt("保存企业信息失败") {
val currentUser = call.requireCurrentUser() PTConfigService.updateEnterpriseInfo(
val req = call.receive<UpdateEnterpriseInfoRequest>() call.requireCurrentUser().id,
val response = PTConfigService.updateEnterpriseInfo(currentUser.id, req) call.receive<UpdateEnterpriseInfoRequest>()
call.respond(ok(response)) )
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "保存企业信息失败"))
} }
} }
// 2. 登记数电账号
get("/digital-account") { get("/digital-account") {
try { call.respondPt("查询数电账号失败") {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
if (currentUser.taxPayerNum == null) { if (currentUser.taxPayerNum == null) {
call.respond(fail(code = "-1", message = "请先完善用户信息")) throw BizException("-1", "请先完善用户信息", HttpStatusCode.OK)
} else {
val response = PTConfigService.getDigitalAccount(currentUser.id)
if (response == null) {
call.respond(ok(emptyMap<String, String>()))
} else {
call.respond(ok(response))
} }
} PTConfigService.getDigitalAccount(currentUser.id) ?: emptyMap<String, String>()
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询数电账号失败"))
} }
} }
put("/digital-account") { put("/digital-account") {
try { call.respondPt("保存数电账号失败") {
val currentUser = call.requireCurrentUser() PTConfigService.updateDigitalAccount(
val req = call.receive<UpdateDigitalAccountRequest>() call.requireCurrentUser().id,
val response = PTConfigService.updateDigitalAccount(currentUser.id, req) call.receive<UpdateDigitalAccountRequest>()
call.respond(ok(response)) )
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "保存数电账号失败"))
} }
} }
// 3. 开票预设数据
get("/preset") { get("/preset") {
try { call.respondPt("查询预设数据失败") {
val currentUser = call.requireCurrentUser() PTConfigService.getPresetData(call.requireCurrentUser().id) ?: emptyMap<String, String>()
val response = PTConfigService.getPresetData(currentUser.id)
if (response == null) {
call.respond(ok(emptyMap<String, String>()))
} else {
call.respond(ok(response))
}
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询预设数据失败"))
} }
} }
put("/preset") { put("/preset") {
try { call.respondPt("保存预设数据失败") {
val currentUser = call.requireCurrentUser() PTConfigService.updatePresetData(
val req = call.receive<UpdatePresetDataRequest>() call.requireCurrentUser().id,
val response = PTConfigService.updatePresetData(currentUser.id, req) call.receive<UpdatePresetDataRequest>()
call.respond(ok(response)) )
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "保存预设数据失败"))
} }
} }
get("/authentication") { get("/authentication") {
try { call.respondPt("获取认证二维码失败") {
val qrcodeType = call.request.queryParameters["qrcodeType"] val profile = call.requireCurrentUser().requirePtProfile()
val currentUser = call.requireCurrentUser() PTAuthService.getAuthenticationQrcode(
val profile = currentUser.requirePtProfile()
val response = PTAuthService.getAuthenticationQrcode(
AuthQrcodeRequest( AuthQrcodeRequest(
taxpayerNum = profile.taxpayerNum, taxpayerNum = profile.taxpayerNum,
account = profile.taxAccount, account = profile.taxAccount,
qrcodeType = qrcodeType qrcodeType = call.request.queryParameters["qrcodeType"]
)
)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
) )
) )
} }
} }
/**
* 查询认证二维码扫码状态
*/
post("/query-auth-status") { post("/query-auth-status") {
try { call.respondPt("查询认证二维码扫码状态失败") {
val req = call.receive<QueryRealNameAuthQrStatusRequest>() PTAuthService.queryAuthQrcodeScanStatus(call.receive<QueryRealNameAuthQrStatusRequest>())
val response = PTAuthService.queryAuthQrcodeScanStatus(req)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} }
} }
/**
* 发送登录短信验证码
*/
post("/send-sms-code") { post("/send-sms-code") {
try { call.respondPt("发送登录短信验证码失败") {
val req = call.receive<GetLoginSmsCodeRequest>() PTAuthService.sendLoginSmsCode(call.receive<GetLoginSmsCodeRequest>())
val response = PTAuthService.sendLoginSmsCode(req)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} }
} }
/**
* 短信验证码登录
*/
post("/sms-login") { post("/sms-login") {
try { call.respondPt("短信验证码登录失败") {
val req = call.receive<SmsLoginRequest>() PTAuthService.smsLogin(call.receive<SmsLoginRequest>())
val response = PTAuthService.smsLogin(req)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} }
} }
} }
@@ -3,9 +3,6 @@
package com.bbit.ticket.route.piaotong package com.bbit.ticket.route.piaotong
import com.bbit.ticket.entity.common.BizException import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.PTException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.request.AskInvoiceRequest import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.request.QueryInvoiceRequest import com.bbit.ticket.entity.request.QueryInvoiceRequest
import com.bbit.ticket.entity.request.RedCreateRequest import com.bbit.ticket.entity.request.RedCreateRequest
@@ -13,228 +10,114 @@ import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.service.piaotong.PTRedService import com.bbit.ticket.service.piaotong.PTRedService
import com.bbit.ticket.utils.requireCurrentUser import com.bbit.ticket.utils.requireCurrentUser
import com.bbit.ticket.utils.requirePtProfile import com.bbit.ticket.utils.requirePtProfile
import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.header
import io.ktor.server.response.respond
import io.ktor.server.response.respondBytes
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
import io.ktor.server.routing.get import io.ktor.server.routing.get
import io.ktor.server.routing.post import io.ktor.server.routing.post
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
fun Route.registerPTInvoiceRoutes() {
/** /**
* 创建红票任务 * 注册票通开票、历史、票样与状态查询接口。
*
* @receiver 当前 Ktor 路由节点。
*/ */
fun Route.registerPTInvoiceRoutes() {
post("/invoiceRed") { post("/invoiceRed") {
try { call.respondPt("红字任务创建失败") {
val user = call.requireCurrentUser() PTRedService.invoiceRed(call.requireCurrentUser(), call.receive<RedCreateRequest>())
val req = call.receive<RedCreateRequest>()
val result = PTRedService.invoiceRed(user, req)
call.respond(ok(result))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} catch (e: BizException) {
call.respond(e.status, fail(code = e.errorCode, message = e.message))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "红字任务创建失败"))
} }
} }
post("/invoiceBlue") { post("/invoiceBlue") {
try { call.respondPt("蓝票任务创建失败") {
val currentUser = call.requireCurrentUser() PTBlueService.invoiceBlue(call.receive<AskInvoiceRequest>(), call.requireCurrentUser())
val req = call.receive<AskInvoiceRequest>()
val response = PTBlueService.invoiceBlue(req, currentUser)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
)
)
} }
} }
get("/invoiceBlueHistory") { get("/invoiceBlueHistory") {
try { call.respondPt("查询开票历史失败") {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1 PTBlueService.getInvoiceBlueHistory(
val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20 userId = currentUser.id,
val invoiceType = call.request.queryParameters["invoiceType"] page = call.request.queryParameters["page"]?.toIntOrNull() ?: 1,
val isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull() pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20,
val batchNo = call.request.queryParameters["batchNo"] invoiceType = call.request.queryParameters["invoiceType"],
val response = PTBlueService.getInvoiceBlueHistory( isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull(),
currentUser.id, page, pageSize, invoiceType, isSuccess, batchNo batchNo = call.request.queryParameters["batchNo"],
) )
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询开票历史失败"))
} }
} }
get("/invoiceBatchNos") { get("/invoiceBatchNos") {
try { call.respondPt("查询批次号列表失败") {
val currentUser = call.requireCurrentUser() PTBlueService.listBatchNos(call.requireCurrentUser().id)
val response = PTBlueService.listBatchNos(currentUser.id)
call.respond(ok(response))
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询批次号列表失败"))
} }
} }
get("/invoiceDetail") { get("/invoiceDetail") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPt("查询发票详情失败") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTBlueService.getInvoiceDetail(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "请传入发票请求流水号")) ?: throw BizException("-1", "未找到该发票记录")
return@get
}
val response = PTBlueService.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("/invoiceDownloadUrl") { get("/invoiceDownloadUrl") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPt("查询发票下载地址失败") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTBlueService.getInvoiceDownloadUrl(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "璇蜂紶鍏ュ彂绁ㄨ姹傛祦姘村彿")) ?: throw BizException("-1", "未找到该发票记录")
return@get
}
val response = PTBlueService.getInvoiceDownloadUrl(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("/invoicePreview") { get("/invoicePreview") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPtPdf("预览票样失败", "$invoiceReqSerialNo.pdf") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTBlueService.getInvoicePreview(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "璇蜂紶鍏ュ彂绁ㄨ姹傛祦姘村彿")) ?: throw BizException("-1", "未找到票样地址")
return@get
}
val bytes = PTBlueService.getInvoicePreview(currentUser.id, invoiceReqSerialNo)
if (bytes == null) {
call.respond(fail(code = "-1", message = "鏈壘鍒扮エ鏍峰湴鍧€"))
return@get
}
call.response.header(
HttpHeaders.ContentDisposition,
"inline; filename=\"${invoiceReqSerialNo}.pdf\""
)
call.respondBytes(bytes, ContentType.Application.Pdf)
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "棰勮绁ㄦ牱澶辫触"))
} }
} }
get("/redInvoiceDownloadUrl") { get("/redInvoiceDownloadUrl") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPt("查询红票下载地址失败") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTRedService.getRedInvoiceDownloadUrl(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "请传入发票请求流水号")) ?: throw BizException("-1", "未找到该红票记录")
return@get
}
val response = PTRedService.getRedInvoiceDownloadUrl(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("/redInvoicePreview") { get("/redInvoicePreview") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPtPdf("预览红票票样失败", "$invoiceReqSerialNo.pdf") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTRedService.getRedInvoicePreview(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "请传入发票请求流水号")) ?: throw BizException("-1", "未找到票样地址")
return@get
}
val bytes = PTRedService.getRedInvoicePreview(currentUser.id, invoiceReqSerialNo)
if (bytes == null) {
call.respond(fail(code = "-1", message = "未找到票样地址"))
return@get
}
call.response.header(
HttpHeaders.ContentDisposition,
"inline; filename=\"${invoiceReqSerialNo}.pdf\""
)
call.respondBytes(bytes, ContentType.Application.Pdf)
} catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "预览红票票样失败"))
} }
} }
get("/queryInvoice") { get("/queryInvoice") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPt("刷新发票状态失败") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTBlueService.queryInvoiceAllInfo(
call.respond(fail(code = "-1", message = "请传入发票请求流水号"))
return@get
}
val response = PTBlueService.queryInvoiceAllInfo(
QueryInvoiceRequest( QueryInvoiceRequest(
taxpayerNum = currentUser.requirePtProfile().taxpayerNum, taxpayerNum = call.requireCurrentUser().requirePtProfile().taxpayerNum,
invoiceReqSerialNo = invoiceReqSerialNo invoiceReqSerialNo = invoiceReqSerialNo,
)
)
call.respond(ok(response))
} catch (e: PTException) {
call.respond(
fail(
code = e.code,
message = e.message,
traceId = e.serialNo
) )
) )
} }
} }
/**
* 查询红票申请信息(冲红原因、收票人信息)
*/
get("/invoiceRedInfo") { get("/invoiceRedInfo") {
try { val invoiceReqSerialNo = call.requiredQueryParameter("invoiceReqSerialNo", "请传入发票请求流水号")
val currentUser = call.requireCurrentUser() ?: return@get
val invoiceReqSerialNo = call.request.queryParameters["invoiceReqSerialNo"] call.respondPt("查询红票申请信息失败") {
if (invoiceReqSerialNo.isNullOrBlank()) { PTRedService.getRedInvoiceInfo(call.requireCurrentUser().id, invoiceReqSerialNo)
call.respond(fail(code = "-1", message = "请传入发票请求流水号")) ?: throw BizException("-1", "未找到红票申请信息")
return@get
}
val response = PTRedService.getRedInvoiceInfo(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 ?: "查询红票申请信息失败"))
} }
} }
} }
@@ -4,6 +4,7 @@ package com.bbit.ticket.service.openapi
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable
import com.bbit.ticket.entity.common.BizException import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
@@ -16,6 +17,7 @@ import com.bbit.ticket.entity.openapi.OpenBlueInvoiceSampleResponse
import com.bbit.ticket.entity.request.AskInvoiceRequest import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.service.piaotong.PTBlueService import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.utils.OpenApiPrincipal import com.bbit.ticket.utils.OpenApiPrincipal
import com.bbit.ticket.utils.net.PTClient
import com.bbit.ticket.utils.plugins.dbQuery import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson import com.bbit.ticket.utils.plugins.myJson
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
@@ -24,6 +26,7 @@ import kotlinx.serialization.encodeToString
import org.jetbrains.exposed.v1.core.SortOrder import org.jetbrains.exposed.v1.core.SortOrder
import org.jetbrains.exposed.v1.core.and 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.core.inList
import org.jetbrains.exposed.v1.jdbc.insert import org.jetbrains.exposed.v1.jdbc.insert
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
@@ -39,20 +42,13 @@ object OpenBlueInvoiceService {
request: OpenBlueInvoiceCreateRequest, request: OpenBlueInvoiceCreateRequest,
): OpenBlueInvoiceCreateResponse { ): OpenBlueInvoiceCreateResponse {
validateCreateRequest(request) validateCreateRequest(request)
val existing = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo) val createRequest = request.withGeneratedInvoiceReqSerialNo(principal.userId)
if (existing != null) {
return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo,
status = existing.status,
)
}
PTBlueService.createBlueInvoice(request.toAskInvoiceRequest(principal), principal.userId) PTBlueService.createBlueInvoice(createRequest.toAskInvoiceRequest(principal), principal.userId)
val detail = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo) val detail = PTBlueService.getInvoiceDetail(principal.userId, createRequest.requireInvoiceReqSerialNo())
return OpenBlueInvoiceCreateResponse( return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo, requestNo = createRequest.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo, invoiceReqSerialNo = createRequest.requireInvoiceReqSerialNo(),
status = detail?.status ?: "PROCESSING", status = detail?.status ?: "PROCESSING",
) )
} }
@@ -110,6 +106,11 @@ object OpenBlueInvoiceService {
throw BizException(ErrorCode.BAD_REQUEST.code, "批次号已存在,一个批次只能开票一次") throw BizException(ErrorCode.BAD_REQUEST.code, "批次号已存在,一个批次只能开票一次")
} }
val reservedSerialNos = mutableSetOf<String>()
val createRequests = request.items.map {
it.withGeneratedInvoiceReqSerialNo(principal.userId, reservedSerialNos)
}
val now = OffsetDateTime.now() val now = OffsetDateTime.now()
dbQuery { dbQuery {
val batchId = OpenInvoiceBatchTable.insert { val batchId = OpenInvoiceBatchTable.insert {
@@ -124,11 +125,11 @@ object OpenBlueInvoiceService {
it[createdAt] = now it[createdAt] = now
}[OpenInvoiceBatchTable.id] }[OpenInvoiceBatchTable.id]
request.items.forEach { item -> createRequests.forEach { item ->
OpenInvoiceBatchItemTable.insert { OpenInvoiceBatchItemTable.insert {
it[OpenInvoiceBatchItemTable.batchId] = batchId it[OpenInvoiceBatchItemTable.batchId] = batchId
it[requestNo] = item.requestNo it[requestNo] = item.requestNo
it[invoiceReqSerialNo] = item.invoiceReqSerialNo it[invoiceReqSerialNo] = item.requireInvoiceReqSerialNo()
it[originalRequestBody] = myJson.encodeToString(item) it[originalRequestBody] = myJson.encodeToString(item)
it[status] = "PENDING" it[status] = "PENDING"
it[createdAt] = now it[createdAt] = now
@@ -329,15 +330,9 @@ object OpenBlueInvoiceService {
throw BizException(ErrorCode.BAD_REQUEST.code, "单次批量最多支持 $MAX_BATCH_SIZE 条") throw BizException(ErrorCode.BAD_REQUEST.code, "单次批量最多支持 $MAX_BATCH_SIZE 条")
} }
request.items.forEach(::validateCreateRequest) request.items.forEach(::validateCreateRequest)
if (request.items.map { it.invoiceReqSerialNo }.distinct().size != request.items.size) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批量明细中 invoiceReqSerialNo 不能重复")
}
} }
private fun validateCreateRequest(request: OpenBlueInvoiceCreateRequest) { private fun validateCreateRequest(request: OpenBlueInvoiceCreateRequest) {
if (request.invoiceReqSerialNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "invoiceReqSerialNo 不能为空")
}
if (request.buyerName.isBlank()) { if (request.buyerName.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "buyerName 不能为空") throw BizException(ErrorCode.BAD_REQUEST.code, "buyerName 不能为空")
} }
@@ -346,10 +341,54 @@ object OpenBlueInvoiceService {
} }
} }
private suspend fun OpenBlueInvoiceCreateRequest.withGeneratedInvoiceReqSerialNo(
userId: Uuid,
reservedSerialNos: MutableSet<String> = mutableSetOf(),
): OpenBlueInvoiceCreateRequest {
var invoiceReqSerialNo: String
do {
invoiceReqSerialNo = PTClient.ptDate()
} while (
invoiceReqSerialNo in reservedSerialNos ||
findUsedInvoiceReqSerialNos(userId, listOf(invoiceReqSerialNo)).isNotEmpty()
)
reservedSerialNos.add(invoiceReqSerialNo)
return copy(invoiceReqSerialNo = invoiceReqSerialNo)
}
private fun OpenBlueInvoiceCreateRequest.requireInvoiceReqSerialNo(): String =
invoiceReqSerialNo
?: throw BizException(ErrorCode.INTERNAL_SERVER_ERROR.code, "invoiceReqSerialNo 生成失败")
private suspend fun findUsedInvoiceReqSerialNos(userId: Uuid, invoiceReqSerialNos: List<String>): Set<String> {
val serialNos = invoiceReqSerialNos.distinct()
if (serialNos.isEmpty()) {
return emptySet()
}
return dbQuery {
val historySerialNos = HistoryInvoiceBasicTable.selectAll()
.where {
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo inList serialNos)
}
.map { it[HistoryInvoiceBasicTable.invoiceReqSerialNo] }
val batchSerialNos = (OpenInvoiceBatchItemTable innerJoin OpenInvoiceBatchTable)
.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq userId) and
(OpenInvoiceBatchItemTable.invoiceReqSerialNo inList serialNos)
}
.map { it[OpenInvoiceBatchItemTable.invoiceReqSerialNo] }
(historySerialNos + batchSerialNos).toSet()
}
}
private fun OpenBlueInvoiceCreateRequest.toAskInvoiceRequest(principal: OpenApiPrincipal): AskInvoiceRequest = private fun OpenBlueInvoiceCreateRequest.toAskInvoiceRequest(principal: OpenApiPrincipal): AskInvoiceRequest =
AskInvoiceRequest( AskInvoiceRequest(
taxpayerNum = principal.taxPayerNum, taxpayerNum = principal.taxPayerNum,
invoiceReqSerialNo = invoiceReqSerialNo, invoiceReqSerialNo = requireInvoiceReqSerialNo(),
invoiceIssueKindCode = invoiceIssueKindCode, invoiceIssueKindCode = invoiceIssueKindCode,
buyerName = buyerName, buyerName = buyerName,
purchaseInvSellerIdType = purchaseInvSellerIdType, purchaseInvSellerIdType = purchaseInvSellerIdType,
@@ -113,7 +113,16 @@ object PTBlueService {
val existing = dbQuery { val existing = dbQuery {
BlueInvoiceDao.findUserIdBySerialNo(invoiceReqSerialNo) BlueInvoiceDao.findUserIdBySerialNo(invoiceReqSerialNo)
} }
return syncInvoiceFromPT(existing, invoiceReqSerialNo, req.taxpayerNum) val result = syncInvoiceFromPT(existing, invoiceReqSerialNo, req.taxpayerNum)
val relatedSerialNos = dbQuery {
BlueInvoiceDao.findRelatedInvoiceReqSerialNos(existing, invoiceReqSerialNo)
}
relatedSerialNos.forEach { relatedSerialNo ->
runCatching {
syncInvoiceFromPT(existing, relatedSerialNo, req.taxpayerNum)
}
}
return result
} }
} }
@@ -51,10 +51,8 @@ object PTRedService {
) )
PTClient.ptPost<QuickRedInvoiceRequest, QuickRedInvoiceResponse>("invoiceRed.pt", req) PTClient.ptPost<QuickRedInvoiceRequest, QuickRedInvoiceResponse>("invoiceRed.pt", req)
dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) } dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) }
// 创建后立即同步一次(非关键,失败忽略) PTBlueService.syncInvoiceFromPT(user.id, his.invoiceReqSerialNo, profile.taxpayerNum)
try {
PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, profile.taxpayerNum) PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, profile.taxpayerNum)
} catch (_: Exception) { }
return "操作成功" return "操作成功"
} }
@@ -0,0 +1,47 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.system
import com.bbit.ticket.dao.system.LogDao
import com.bbit.ticket.utils.plugins.dbQuery
import io.ktor.server.application.ApplicationCall
import kotlin.uuid.ExperimentalUuidApi
object ApiAccessLogService {
/**
* 保存开放接口访问日志。
*
* @param call 当前接口请求上下文。
* @param appKey 调用方应用密钥。
* @param appName 调用方应用名称。
* @param requestBody 请求体 JSON 文本。
* @param responseCode 响应业务状态码。
* @param responseBody 响应体 JSON 文本。
* @param status 日志状态,SUCCESS 或 FAILED。
* @param errorMessage 失败时的错误信息。
* @param costMs 接口耗时,单位毫秒。
*/
suspend fun save(
call: ApplicationCall,
appKey: String?,
appName: String?,
requestBody: String?,
responseCode: String?,
responseBody: String?,
status: String,
errorMessage: String?,
costMs: Long,
) = dbQuery {
LogDao.saveApiAccessLog(
call = call,
appKey = appKey,
appName = appName,
requestBody = requestBody,
responseCode = responseCode,
responseBody = responseBody,
status = status,
errorMessage = errorMessage,
costMs = costMs,
)
}
}
+554 -2
View File
@@ -1,9 +1,561 @@
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.TimeoutCancellationException
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.Semaphore
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.sync.withPermit
import kotlinx.coroutines.withTimeout
import kotlin.test.Test import kotlin.test.Test
import kotlin.test.assertEquals import kotlin.test.assertEquals
import kotlin.test.assertTrue
class Test { class Test {
@Test @Test
fun helloWorld() { fun simulateOpenInvoiceBatchScheduler() = runBlocking {
print("Hello World!") val store = InMemoryInvoiceStore()
val customerClient = FakeCustomerInvoiceClient(total = 123)
val ptClient = FakePtClient()
val scheduler = DemoOpenInvoiceBatchScheduler(
store = store,
customerClient = customerClient,
ptClient = ptClient,
pageSize = 5,
issueConcurrency = 3,
)
store.createBatch(batchNo = "BATCH-DEMO-001")
scheduler.start()
try {
withTimeout(20_000) {
while (!store.isBatchResolved("BATCH-DEMO-001")) {
delay(1_500)
println(store.batchSnapshot("BATCH-DEMO-001"))
} }
} }
} catch (e: TimeoutCancellationException) {
error("scheduler did not finish in time: ${store.batchSnapshot("BATCH-DEMO-001")}")
} finally {
scheduler.stop()
}
val batch = store.getBatch("BATCH-DEMO-001")
val items = store.listItems(batch.id)
println("final batch = $batch")
println("final items =")
items.forEach { println(it) }
assertEquals(23, batch.totalCount)
assertEquals(20, batch.successCount)
assertEquals(3, batch.failedCount)
assertEquals(0, batch.processingCount)
assertEquals(BatchStatus.PARTIAL_FAILED, batch.status)
assertTrue(batch.resolved)
}
}
/**
* 演示版调度器:
* - FetchWorker:按 batchNo 分页拉甲方数据,边拉边落“库”
* - IssueWorker:扫描 PENDING 明细,并发调用 PT 开票
* - QueryWorker:扫描 QUERY_PENDING 明细,按 nextQueryAt 轮询 PT 结果
* - SummaryWorker:持续汇总批次进度
*/
private class DemoOpenInvoiceBatchScheduler(
private val store: InMemoryInvoiceStore,
private val customerClient: FakeCustomerInvoiceClient,
private val ptClient: FakePtClient,
private val pageSize: Int,
issueConcurrency: Int,
) {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val issueSemaphore = Semaphore(issueConcurrency)
@Volatile
private var running = true
fun start() {
scope.launch { recoverInterruptedTasks() }
scope.launch { fetchLoop() }
scope.launch { issueLoop() }
scope.launch { queryLoop() }
scope.launch { summaryLoop() }
}
fun stop() {
running = false
}
private suspend fun recoverInterruptedTasks() {
// 真实项目里这里处理服务崩溃留下的 ISSUING / QUERYING / 过期锁。
store.recoverStuckItems()
}
private suspend fun fetchLoop() {
while (running) {
val batch = store.claimFetchBatch()
if (batch == null) {
delay(100)
continue
}
var pageNo = batch.fetchPageNo + 1
while (running) {
val page = customerClient.fetchItems(batch.batchNo, pageNo, pageSize)
store.saveFetchedItems(batch.id, page.items)
store.markPageFetched(
batchId = batch.id,
pageNo = pageNo,
fetchedCount = page.items.size,
total = page.total,
)
val reachedTail = !page.hasNext || page.items.isEmpty() || page.items.size < pageSize
if (reachedTail) {
store.markFetchFinished(batch.id)
break
}
pageNo++
}
}
}
private suspend fun issueLoop() {
while (running) {
val items = store.claimIssueItems(limit = 20)
if (items.isEmpty()) {
delay(100)
continue
}
coroutineScope {
items.forEach { item ->
launch {
issueSemaphore.withPermit {
issueOne(item)
}
}
}
}
}
}
private suspend fun issueOne(item: InvoiceItemRecord) {
runCatching {
ptClient.issueInvoice(item.invoiceReqSerialNo)
}.onSuccess {
store.markIssueSubmitted(
itemId = item.id,
nextQueryAtMs = nowMs() + 200,
)
}.onFailure { error ->
store.markItemFailed(item.id, "ISSUE_FAILED", error.message ?: "issue failed")
}
}
private suspend fun queryLoop() {
while (running) {
val items = store.claimQueryableItems(limit = 30, nowMs = nowMs())
if (items.isEmpty()) {
delay(100)
continue
}
items.forEach { item ->
queryOne(item)
}
}
}
private suspend fun queryOne(item: InvoiceItemRecord) {
val result = ptClient.queryInvoice(item.invoiceReqSerialNo)
when (result.status) {
PtInvoiceStatus.SUCCESS -> store.markItemSuccess(item.id, result.invoiceNo)
PtInvoiceStatus.FAILED -> store.markItemFailed(item.id, result.code, result.message)
PtInvoiceStatus.PROCESSING -> {
val nextDelayMs = when {
item.queryAttempts < 5 -> 200L
item.queryAttempts < 20 -> 500L
else -> 1_000L
}
store.markQueryPending(item.id, nowMs() + nextDelayMs)
}
}
}
private suspend fun summaryLoop() {
while (running) {
store.refreshAllBatchSummary()
delay(200)
}
}
private fun nowMs(): Long = System.currentTimeMillis()
}
private class FakeCustomerInvoiceClient(private val total: Int) {
suspend fun fetchItems(batchNo: String, pageNo: Int, pageSize: Int): CustomerInvoicePage {
delay(120)
val start = (pageNo - 1) * pageSize
if (start >= total) {
return CustomerInvoicePage(total = total, hasNext = false, items = emptyList())
}
val endExclusive = minOf(start + pageSize, total)
val items = (start until endExclusive).map { index ->
val no = index + 1
CustomerInvoiceItem(
sourceBizNo = "ORDER-${no.toString().padStart(3, '0')}",
invoiceReqSerialNo = "BL20260520${no.toString().padStart(8, '0')}",
buyerName = "测试客户$no",
amount = "100.00",
rawJson = """{"batchNo":"$batchNo","sourceBizNo":"ORDER-$no"}""",
)
}
return CustomerInvoicePage(
total = total,
hasNext = endExclusive < total,
items = items,
)
}
}
private class FakePtClient {
private val queryCountBySerialNo = mutableMapOf<String, Int>()
suspend fun issueInvoice(invoiceReqSerialNo: String) {
delay(150)
// 这里模拟 PT 开票接口只是“提交成功”,不代表最终开票成功。
println("PT issue submitted: $invoiceReqSerialNo")
}
suspend fun queryInvoice(invoiceReqSerialNo: String): PtQueryResult {
delay(80)
val queryCount = (queryCountBySerialNo[invoiceReqSerialNo] ?: 0) + 1
queryCountBySerialNo[invoiceReqSerialNo] = queryCount
val serialNumber = invoiceReqSerialNo.takeLast(8).toInt()
val shouldFail = serialNumber % 7 == 0
return when {
queryCount < 3 -> PtQueryResult(PtInvoiceStatus.PROCESSING, "7777", "开票处理中")
shouldFail -> PtQueryResult(PtInvoiceStatus.FAILED, "9999", "模拟开票失败")
else -> PtQueryResult(
status = PtInvoiceStatus.SUCCESS,
code = "0000",
message = "开票成功",
invoiceNo = "NO-${invoiceReqSerialNo.takeLast(8)}",
)
}
}
}
private class InMemoryInvoiceStore {
private val mutex = Mutex()
private val batches = linkedMapOf<Long, InvoiceBatchRecord>()
private val items = linkedMapOf<Long, InvoiceItemRecord>()
private var batchIdSequence = 1L
private var itemIdSequence = 1L
suspend fun createBatch(batchNo: String): InvoiceBatchRecord = mutex.withLock {
check(batches.values.none { it.batchNo == batchNo }) { "batch already exists: $batchNo" }
val batch = InvoiceBatchRecord(
id = batchIdSequence++,
batchNo = batchNo,
status = BatchStatus.CREATED,
)
batches[batch.id] = batch
batch
}
suspend fun claimFetchBatch(): InvoiceBatchRecord? = mutex.withLock {
val batch = batches.values.firstOrNull {
!it.fetchFinished && (it.status == BatchStatus.CREATED || it.status == BatchStatus.FETCHING)
} ?: return@withLock null
val claimed = batch.copy(status = BatchStatus.FETCHING)
batches[batch.id] = claimed
claimed
}
suspend fun saveFetchedItems(batchId: Long, fetchedItems: List<CustomerInvoiceItem>) = mutex.withLock {
fetchedItems.forEach { fetched ->
val exists = items.values.any {
it.batchId == batchId && it.sourceBizNo == fetched.sourceBizNo
}
if (!exists) {
val item = InvoiceItemRecord(
id = itemIdSequence++,
batchId = batchId,
sourceBizNo = fetched.sourceBizNo,
invoiceReqSerialNo = fetched.invoiceReqSerialNo,
buyerName = fetched.buyerName,
amount = fetched.amount,
originalRequestBody = fetched.rawJson,
status = ItemStatus.PENDING,
)
items[item.id] = item
}
}
}
suspend fun markPageFetched(batchId: Long, pageNo: Int, fetchedCount: Int, total: Int) = mutex.withLock {
val batch = batches.getValue(batchId)
batches[batchId] = batch.copy(
totalCount = total,
fetchedCount = batch.fetchedCount + fetchedCount,
fetchPageNo = pageNo,
updatedAtMs = nowMs(),
)
}
suspend fun markFetchFinished(batchId: Long) = mutex.withLock {
val batch = batches.getValue(batchId)
batches[batchId] = batch.copy(
status = BatchStatus.FETCHED,
fetchFinished = true,
updatedAtMs = nowMs(),
)
}
suspend fun claimIssueItems(limit: Int): List<InvoiceItemRecord> = mutex.withLock {
val claimed = items.values
.filter { it.status == ItemStatus.PENDING }
.take(limit)
.map { item ->
val updated = item.copy(
status = ItemStatus.ISSUING,
issueAttempts = item.issueAttempts + 1,
updatedAtMs = nowMs(),
)
items[item.id] = updated
updated
}
if (claimed.isNotEmpty()) {
setBatchesStatus(claimed.map { it.batchId }.toSet(), BatchStatus.ISSUING)
}
claimed
}
suspend fun markIssueSubmitted(itemId: Long, nextQueryAtMs: Long) = mutex.withLock {
val item = items.getValue(itemId)
items[itemId] = item.copy(
status = ItemStatus.QUERY_PENDING,
nextQueryAtMs = nextQueryAtMs,
updatedAtMs = nowMs(),
)
}
suspend fun claimQueryableItems(limit: Int, nowMs: Long): List<InvoiceItemRecord> = mutex.withLock {
val claimed = items.values
.filter { it.status == ItemStatus.QUERY_PENDING && it.nextQueryAtMs <= nowMs }
.take(limit)
.map { item ->
val updated = item.copy(
status = ItemStatus.QUERYING,
queryAttempts = item.queryAttempts + 1,
updatedAtMs = nowMs(),
)
items[item.id] = updated
updated
}
if (claimed.isNotEmpty()) {
setBatchesStatus(claimed.map { it.batchId }.toSet(), BatchStatus.QUERYING)
}
claimed
}
suspend fun markQueryPending(itemId: Long, nextQueryAtMs: Long) = mutex.withLock {
val item = items.getValue(itemId)
items[itemId] = item.copy(
status = ItemStatus.QUERY_PENDING,
nextQueryAtMs = nextQueryAtMs,
updatedAtMs = nowMs(),
)
}
suspend fun markItemSuccess(itemId: Long, invoiceNo: String?) = mutex.withLock {
val item = items.getValue(itemId)
items[itemId] = item.copy(
status = ItemStatus.SUCCESS,
invoiceNo = invoiceNo,
lastErrorCode = null,
lastErrorMessage = null,
updatedAtMs = nowMs(),
)
}
suspend fun markItemFailed(itemId: Long, code: String, message: String) = mutex.withLock {
val item = items.getValue(itemId)
items[itemId] = item.copy(
status = ItemStatus.FAILED,
lastErrorCode = code,
lastErrorMessage = message,
updatedAtMs = nowMs(),
)
}
suspend fun refreshAllBatchSummary() = mutex.withLock {
batches.values.forEach { batch ->
val batchItems = items.values.filter { it.batchId == batch.id }
val success = batchItems.count { it.status == ItemStatus.SUCCESS }
val failed = batchItems.count { it.status == ItemStatus.FAILED }
val processing = batchItems.size - success - failed
val resolved = batch.fetchFinished && batchItems.isNotEmpty() && processing == 0
val status = when {
!batch.fetchFinished -> BatchStatus.FETCHING
!resolved && batchItems.any { it.status == ItemStatus.PENDING || it.status == ItemStatus.ISSUING } ->
BatchStatus.ISSUING
!resolved -> BatchStatus.QUERYING
failed == 0 -> BatchStatus.FINISHED
success == 0 -> BatchStatus.FAILED
else -> BatchStatus.PARTIAL_FAILED
}
batches[batch.id] = batch.copy(
status = status,
submittedCount = batchItems.count {
it.status != ItemStatus.PENDING && it.status != ItemStatus.ISSUING
},
successCount = success,
failedCount = failed,
processingCount = processing,
resolved = resolved,
updatedAtMs = nowMs(),
)
}
}
suspend fun recoverStuckItems() = mutex.withLock {
items.values.forEach { item ->
when (item.status) {
ItemStatus.ISSUING -> items[item.id] = item.copy(status = ItemStatus.QUERY_PENDING)
ItemStatus.QUERYING -> items[item.id] = item.copy(status = ItemStatus.QUERY_PENDING)
else -> Unit
}
}
}
suspend fun isBatchResolved(batchNo: String): Boolean = mutex.withLock {
batches.values.first { it.batchNo == batchNo }.resolved
}
suspend fun getBatch(batchNo: String): InvoiceBatchRecord = mutex.withLock {
batches.values.first { it.batchNo == batchNo }
}
suspend fun listItems(batchId: Long): List<InvoiceItemRecord> = mutex.withLock {
items.values.filter { it.batchId == batchId }
}
suspend fun batchSnapshot(batchNo: String): String = mutex.withLock {
val batch = batches.values.first { it.batchNo == batchNo }
"batch=${batch.batchNo}, status=${batch.status}, fetched=${batch.fetchedCount}/${batch.totalCount}, " +
"success=${batch.successCount}, failed=${batch.failedCount}, processing=${batch.processingCount}"
}
private fun setBatchesStatus(batchIds: Set<Long>, status: BatchStatus) {
batchIds.forEach { batchId ->
val batch = batches.getValue(batchId)
if (!batch.resolved) {
batches[batchId] = batch.copy(status = status, updatedAtMs = nowMs())
}
}
}
private fun nowMs(): Long = System.currentTimeMillis()
}
private data class CustomerInvoicePage(
val total: Int,
val hasNext: Boolean,
val items: List<CustomerInvoiceItem>,
)
private data class CustomerInvoiceItem(
val sourceBizNo: String,
val invoiceReqSerialNo: String,
val buyerName: String,
val amount: String,
val rawJson: String,
)
private data class InvoiceBatchRecord(
val id: Long,
val batchNo: String,
val status: BatchStatus,
val totalCount: Int = 0,
val fetchedCount: Int = 0,
val submittedCount: Int = 0,
val successCount: Int = 0,
val failedCount: Int = 0,
val processingCount: Int = 0,
val fetchPageNo: Int = 0,
val fetchFinished: Boolean = false,
val resolved: Boolean = false,
val updatedAtMs: Long = System.currentTimeMillis(),
)
private data class InvoiceItemRecord(
val id: Long,
val batchId: Long,
val sourceBizNo: String,
val invoiceReqSerialNo: String,
val buyerName: String,
val amount: String,
val originalRequestBody: String,
val status: ItemStatus,
val issueAttempts: Int = 0,
val queryAttempts: Int = 0,
val nextQueryAtMs: Long = 0,
val invoiceNo: String? = null,
val lastErrorCode: String? = null,
val lastErrorMessage: String? = null,
val updatedAtMs: Long = System.currentTimeMillis(),
)
private data class PtQueryResult(
val status: PtInvoiceStatus,
val code: String,
val message: String,
val invoiceNo: String? = null,
)
private enum class BatchStatus {
CREATED,
FETCHING,
FETCHED,
ISSUING,
QUERYING,
FINISHED,
PARTIAL_FAILED,
FAILED,
}
private enum class ItemStatus {
PENDING,
ISSUING,
QUERY_PENDING,
QUERYING,
SUCCESS,
FAILED,
}
private enum class PtInvoiceStatus {
PROCESSING,
SUCCESS,
FAILED,
}
+1
View File
@@ -530,6 +530,7 @@ export interface InvoiceHistoryItem {
export const invoiceKindMap: Record<string, string> = { export const invoiceKindMap: Record<string, string> = {
'81': '数电专票', '81': '数电专票',
'82': '数电普票', '82': '数电普票',
'83': '数电机动车销售统一发票',
'87': '机动车发票', '87': '机动车发票',
'10': '电子普票', '10': '电子普票',
'08': '电子专票', '08': '电子专票',
@@ -44,6 +44,7 @@
<div class="card-body card-body-fill table-fill"> <div class="card-body card-body-fill table-fill">
<n-data-table <n-data-table
remote
:columns="columns" :columns="columns"
:data="dataSource" :data="dataSource"
:loading="loading" :loading="loading"
@@ -100,9 +101,7 @@
<div class="detail-grid"> <div class="detail-grid">
<div class="detail-item"> <div class="detail-item">
<span>发票种类</span> <span>发票种类</span>
<strong>{{ <strong>{{ formatInvoiceKind(detailItem.invoiceKindCode) }}</strong>
invoiceKindMap[detailItem.invoiceKindCode] || detailItem.invoiceKindCode
}}</strong>
</div> </div>
<div class="detail-item"> <div class="detail-item">
<span>发票号码</span> <span>发票号码</span>
@@ -511,6 +510,7 @@ import type {
RedCreateRequest, RedCreateRequest,
RedInvoiceInfo RedInvoiceInfo
} from '@/api/piaotong' } from '@/api/piaotong'
import { renderPagePrefix } from '@/utils/pagination'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
const invoiceTypeMap: Record<string, string> = { const invoiceTypeMap: Record<string, string> = {
@@ -533,6 +533,8 @@ const redFlagMap: Record<string, string> = {
PART_RED: '部分冲红' PART_RED: '部分冲红'
} }
const redInvoiceKindCodes = new Set(['81', '82', '83', '87'])
const invIssueChannelMap: Record<string, string> = { const invIssueChannelMap: Record<string, string> = {
'0': 'RPA电子税局', '0': 'RPA电子税局',
'1': '乐企自用', '1': '乐企自用',
@@ -573,6 +575,19 @@ function statusTagType(status: string): 'warning' | 'info' | 'success' | 'error'
} }
} }
function formatInvoiceKind(invoiceKindCode?: string) {
return invoiceKindCode ? invoiceKindMap[invoiceKindCode] || invoiceKindCode : '-'
}
function canCreateRedInvoice(row: InvoiceHistoryItem) {
return (
activeTab.value === 'BLUE' &&
row.status === 'SUCCESS' &&
(!row.redFlag || row.redFlag === 'NOT_RED') &&
redInvoiceKindCodes.has(row.invoiceKindCode)
)
}
const activeTab = ref('BLUE') const activeTab = ref('BLUE')
const selectedStatus = ref<string | null>(null) const selectedStatus = ref<string | null>(null)
const selectedBatchNo = ref<string | null>(null) const selectedBatchNo = ref<string | null>(null)
@@ -665,7 +680,7 @@ const pagination = reactive({
showSizePicker: true, showSizePicker: true,
pageSizes: [10, 20, 50, 100], pageSizes: [10, 20, 50, 100],
pageSlot: 7, pageSlot: 7,
prefix: ({ itemCount }: { itemCount?: number }) => `${itemCount ?? 0}` prefix: renderPagePrefix
}) })
async function fetchData() { async function fetchData() {
@@ -711,17 +726,29 @@ function getRowActions(row: InvoiceHistoryItem) {
actions.push({ label: '查看票样', icon: FileSearch, onClick: () => openSamplePreview(row) }) actions.push({ label: '查看票样', icon: FileSearch, onClick: () => openSamplePreview(row) })
} }
if ( if (canCreateRedInvoice(row)) {
activeTab.value === 'BLUE' &&
row.status === 'SUCCESS' &&
(!row.redFlag || row.redFlag === 'NOT_RED')
) {
actions.push({ label: '冲红', icon: RotateCcw, onClick: () => startRedTask(row) }) actions.push({ label: '冲红', icon: RotateCcw, onClick: () => startRedTask(row) })
} }
return actions return actions
} }
function getActionsColumnWidth() {
const buttonWidthMap: Record<string, number> = {
详情: 58,
刷新: 58,
查看票样: 86,
冲红: 58
}
const rowWidths = dataSource.value.map((row) => {
const actions = getRowActions(row)
const buttonWidth = actions.reduce((sum, action) => sum + (buttonWidthMap[action.label] || 64), 0)
const gapWidth = Math.max(0, actions.length - 1) * 6
return buttonWidth + gapWidth + 20
})
return Math.max(180, ...rowWidths)
}
async function openSamplePreview(item: InvoiceHistoryItem) { async function openSamplePreview(item: InvoiceHistoryItem) {
showSamplePreview.value = true showSamplePreview.value = true
sampleLoading.value = true sampleLoading.value = true
@@ -796,6 +823,16 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
() => invoiceStatusMap[row.status] || row.status () => invoiceStatusMap[row.status] || row.status
) )
}, },
{
title: '失败原因',
key: 'msg',
width: 220,
ellipsis: { tooltip: true },
render: (row: InvoiceHistoryItem) =>
row.status === 'FAILED' && row.msg
? row.msg
: h('span', { style: 'color:#9ca3af' }, '-')
},
{ {
title: '冲红状态', title: '冲红状态',
key: 'redFlag', key: 'redFlag',
@@ -836,8 +873,9 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
{ {
title: '发票种类', title: '发票种类',
key: 'invoiceKindCode', key: 'invoiceKindCode',
width: 110, width: 150,
render: (row: InvoiceHistoryItem) => invoiceKindMap[row.invoiceKindCode] || row.invoiceKindCode ellipsis: { tooltip: true },
render: (row: InvoiceHistoryItem) => formatInvoiceKind(row.invoiceKindCode)
}, },
{ {
title: '价税合计', title: '价税合计',
@@ -856,13 +894,14 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
width: 180, width: getActionsColumnWidth(),
minWidth: 180,
fixed: 'right' as const, fixed: 'right' as const,
render: (row: InvoiceHistoryItem) => { render: (row: InvoiceHistoryItem) => {
const actions = getRowActions(row) const actions = getRowActions(row)
return h( return h(
'div', 'div',
{ style: 'display:flex;gap:6px;align-items:center;flex-wrap:wrap' }, { class: 'table-actions' },
actions.map((btn) => { actions.map((btn) => {
const isLoading = btn.label === '刷新' && refreshingSet.has(row.invoiceReqSerialNo) const isLoading = btn.label === '刷新' && refreshingSet.has(row.invoiceReqSerialNo)
const Icon = btn.icon const Icon = btn.icon
@@ -1204,6 +1243,18 @@ onMounted(() => {
background: #fafafa !important; background: #fafafa !important;
} }
.table-actions {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
white-space: nowrap;
}
.table-actions :deep(.n-button) {
flex: 0 0 auto;
}
.sample-preview { .sample-preview {
padding: 16px; padding: 16px;
} }