通用开票/查询接口

This commit is contained in:
BBIT-Kai
2026-05-20 16:20:40 +08:00
parent cdcfaa192c
commit ccc164b176
24 changed files with 1247 additions and 30 deletions
+260
View File
@@ -0,0 +1,260 @@
[
{
"project_id": "-1",
"target_id": "open_blue_invoice_create",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票单笔开票",
"sort": 1,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "POST",
"url": "{{baseUrl}}/api/open/v1/blue-invoices",
"request": {
"auth": {
"type": "noauth",
"kv": { "key": "", "value": "", "in": "header" },
"bearer": { "key": "" },
"basic": { "username": "", "password": "" },
"noauth": {}
},
"body": {
"mode": "json",
"parameter": [],
"raw": "{\n \"requestNo\": \"REQ202605200001\",\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"测试购买方有限公司\",\n \"buyerTaxpayerNum\": \"91350000000000000X\",\n \"takerName\": \"张三\",\n \"takerTel\": \"13800000000\",\n \"takerEmail\": \"test@example.com\",\n \"remark\": \"开放API测试开票\",\n \"itemList\": [\n {\n \"goodsName\": \"信息技术服务\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"100.00\",\n \"invoiceAmount\": \"100.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n}",
"raw_parameter": [],
"raw_schema": { "type": "object" },
"binary": null
},
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" },
{ "key": "Content-Type", "value": "application/json", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": { "parameter": [] },
"tabs_default_active_key": "body"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"requestNo\": \"REQ202605200001\",\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"status\": \"PROCESSING\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": {
"name": "成功",
"is_default": 1,
"code": "200",
"sleep": 0,
"content_type": "json",
"verify_type": "schema",
"mock": "",
"schema": { "type": "object", "properties": {} }
}
}
]
},
"description": "Header 必填:X-Api-Key。taxpayerNum/account 从绑定用户信息中读取,不需要在请求中传。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_query",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票单笔查询",
"sort": 2,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/{{invoiceReqSerialNo}}",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "invoiceReqSerialNo", "value": "OPEN202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"status\": \"SUCCESS\",\n \"code\": \"0000\",\n \"message\": \"开票成功\",\n \"invoiceCode\": null,\n \"invoiceNo\": \"12345678\",\n \"electronicInvoiceNo\": \"25100000000000000000\",\n \"invoiceDate\": \"2026-05-20 10:00:00\",\n \"buyerName\": \"测试购买方有限公司\",\n \"totalAmount\": \"100.00\",\n \"downloadUrl\": \"https://example.com/invoice.pdf\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "查询时会同步票通状态,然后返回平台标准化结果。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_sample",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票票样查询",
"sort": 3,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/{{invoiceReqSerialNo}}/sample",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "invoiceReqSerialNo", "value": "OPEN202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"invoiceReqSerialNo\": \"OPEN202605200001\",\n \"downloadUrl\": \"https://example.com/invoice.pdf\"\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "返回已保存的 downloadUrl,不返回 base64 文件内容。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_batch_create",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票批量开票",
"sort": 4,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "POST",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/batches",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": {
"mode": "json",
"parameter": [],
"raw": "{\n \"batchNo\": \"BATCH202605200001\",\n \"items\": [\n {\n \"requestNo\": \"REQ202605200101\",\n \"invoiceReqSerialNo\": \"OPEN202605200101\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"批量测试客户A\",\n \"buyerTaxpayerNum\": \"91350000000000000X\",\n \"remark\": \"开放API批量测试A\",\n \"itemList\": [\n {\n \"goodsName\": \"信息技术服务\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"50.00\",\n \"invoiceAmount\": \"50.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n },\n {\n \"requestNo\": \"REQ202605200102\",\n \"invoiceReqSerialNo\": \"OPEN202605200102\",\n \"invoiceIssueKindCode\": \"82\",\n \"buyerName\": \"批量测试客户B\",\n \"remark\": \"开放API批量测试B\",\n \"itemList\": [\n {\n \"goodsName\": \"软件服务费\",\n \"taxClassificationCode\": \"3040201010000000000\",\n \"includeTaxFlag\": \"1\",\n \"quantity\": \"1\",\n \"unitPrice\": \"80.00\",\n \"invoiceAmount\": \"80.00\",\n \"taxRateValue\": \"0.06\"\n }\n ]\n }\n ]\n}",
"raw_parameter": [],
"raw_schema": { "type": "object" },
"binary": null
},
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" },
{ "key": "Content-Type", "value": "application/json", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": { "parameter": [] },
"tabs_default_active_key": "body"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"batchNo\": \"BATCH202605200001\",\n \"status\": \"PROCESSING\",\n \"total\": 2\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "批量接口提交后异步处理,使用批量结果查询接口查看进度。",
"tags": ["开放API", "蓝票"]
},
{
"project_id": "-1",
"target_id": "open_blue_invoice_batch_query",
"parent_id": "0",
"target_type": "api",
"name": "开放API-蓝票批量结果查询",
"sort": 5,
"version": 1,
"mark_id": "1",
"status": 1,
"is_changed": -1,
"method": "GET",
"url": "{{baseUrl}}/api/open/v1/blue-invoices/batches/{{batchNo}}",
"request": {
"auth": { "type": "noauth", "kv": { "key": "", "value": "", "in": "header" }, "bearer": { "key": "" }, "basic": { "username": "", "password": "" }, "noauth": {} },
"body": { "mode": "none", "parameter": [], "raw": "", "raw_parameter": [], "raw_schema": { "type": "object" }, "binary": null },
"pre_tasks": [],
"post_tasks": [],
"header": {
"parameter": [
{ "key": "X-Api-Key", "value": "{{apiKey}}", "is_checked": 1, "type": "string" }
]
},
"query": { "parameter": [], "query_add_equal": 1 },
"cookie": { "parameter": [], "cookie_encode": 1 },
"restful": {
"parameter": [
{ "key": "batchNo", "value": "BATCH202605200001", "is_checked": 1, "type": "string" }
]
},
"tabs_default_active_key": "restful"
},
"response": {
"is_check_result": 1,
"example": [
{
"example_id": "1",
"raw": "{\n \"code\": \"0\",\n \"message\": \"成功\",\n \"data\": {\n \"batchNo\": \"BATCH202605200001\",\n \"status\": \"PROCESSING\",\n \"total\": 2,\n \"success\": 1,\n \"failed\": 0,\n \"processing\": 1,\n \"items\": [\n {\n \"requestNo\": \"REQ202605200101\",\n \"invoiceReqSerialNo\": \"OPEN202605200101\",\n \"status\": \"SUCCESS\",\n \"errorCode\": null,\n \"errorMessage\": null\n },\n {\n \"requestNo\": \"REQ202605200102\",\n \"invoiceReqSerialNo\": \"OPEN202605200102\",\n \"status\": \"PROCESSING\",\n \"errorCode\": null,\n \"errorMessage\": null\n }\n ]\n }\n}",
"raw_parameter": [],
"headers": [],
"expect": { "name": "成功", "is_default": 1, "code": "200", "sleep": 0, "content_type": "json", "verify_type": "schema", "mock": "", "schema": { "type": "object", "properties": {} } }
}
]
},
"description": "批次状态:PROCESSING / SUCCESS / FAILED / PARTIAL_FAILED。",
"tags": ["开放API", "蓝票"]
}
]
@@ -15,7 +15,8 @@ import com.bbit.ticket.utils.plugins.configureSerialization
import com.bbit.ticket.utils.plugins.configureStatusPages import com.bbit.ticket.utils.plugins.configureStatusPages
import com.bbit.ticket.utils.plugins.configureTrace import com.bbit.ticket.utils.plugins.configureTrace
import com.bbit.ticket.route.piaotong.registerPTAuthRoutes import com.bbit.ticket.route.piaotong.registerPTAuthRoutes
import com.bbit.ticket.route.piaotong.registerPTiInvoiceRoutes import com.bbit.ticket.route.piaotong.registerPTInvoiceRoutes
import com.bbit.ticket.route.openapi.registerOpenBlueInvoiceRoutes
import com.bbit.ticket.route.system.registerDictRoutes import com.bbit.ticket.route.system.registerDictRoutes
import com.bbit.ticket.route.system.registerLogsQueryRoutes import com.bbit.ticket.route.system.registerLogsQueryRoutes
import com.bbit.ticket.route.system.registerMenuRoutes import com.bbit.ticket.route.system.registerMenuRoutes
@@ -64,11 +65,18 @@ fun Application.module() {
registerMenuRoutes() registerMenuRoutes()
registerDictRoutes() registerDictRoutes()
registerLogsQueryRoutes() registerLogsQueryRoutes()
route("/open/v1") {
route("/blue-invoices") {
registerOpenBlueInvoiceRoutes()
}
route("/f8") {
}
}
route("/pt") { route("/pt") {
authenticate("auth-jwt") { authenticate("auth-jwt") {
registerPTAuthRoutes() registerPTAuthRoutes()
registerPTiInvoiceRoutes() registerPTInvoiceRoutes()
} }
} }
} }
@@ -7,6 +7,8 @@ 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.HistoryInvoiceVoucherTable import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.entity.common.PageResult import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.request.AskInvoiceRequest import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.entity.response.GetInvoiceInfoResponse import com.bbit.ticket.entity.response.GetInvoiceInfoResponse
@@ -24,7 +26,9 @@ import org.jetbrains.exposed.v1.core.ResultRow
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.core.isNull import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.core.like
import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.core.neq
import org.jetbrains.exposed.v1.core.statements.UpdateBuilder import org.jetbrains.exposed.v1.core.statements.UpdateBuilder
import org.jetbrains.exposed.v1.jdbc.deleteWhere import org.jetbrains.exposed.v1.jdbc.deleteWhere
@@ -41,6 +45,12 @@ import kotlin.uuid.Uuid
object BlueInvoiceDao { object BlueInvoiceDao {
fun listBatchNos(userId: Uuid): List<String> =
OpenInvoiceBatchTable.selectAll()
.where { OpenInvoiceBatchTable.userId eq userId }
.orderBy(OpenInvoiceBatchTable.createdAt, SortOrder.DESC)
.map { it[OpenInvoiceBatchTable.batchNo] }
// ============================================= // =============================================
// 开票历史查询 // 开票历史查询
// ============================================= // =============================================
@@ -53,7 +63,8 @@ object BlueInvoiceDao {
page: Int, page: Int,
pageSize: Int, pageSize: Int,
invoiceType: String? = null, invoiceType: String? = null,
isSuccess: Boolean? = null isSuccess: Boolean? = null,
batchNo: String? = null,
): PageResult<InvoiceHistoryItem> { ): PageResult<InvoiceHistoryItem> {
val conditions = mutableListOf<Op<Boolean>>() val conditions = mutableListOf<Op<Boolean>>()
conditions.add(HistoryInvoiceBasicTable.userId eq userId) conditions.add(HistoryInvoiceBasicTable.userId eq userId)
@@ -80,18 +91,57 @@ object BlueInvoiceDao {
} }
} }
val normalizedBatchNo = batchNo?.trim()?.takeIf { it.isNotEmpty() }
if (normalizedBatchNo != null) {
val matchedSerialNos = (OpenInvoiceBatchItemTable innerJoin OpenInvoiceBatchTable)
.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq userId) and
(OpenInvoiceBatchTable.batchNo like "%$normalizedBatchNo%")
}
.map { it[OpenInvoiceBatchItemTable.invoiceReqSerialNo] }
.distinct()
if (matchedSerialNos.isEmpty()) {
return PageResult(emptyList(), page, pageSize, 0)
}
conditions.add(HistoryInvoiceBasicTable.invoiceReqSerialNo inList matchedSerialNos)
}
val whereClause = conditions.reduce { a, b -> a and b } val whereClause = conditions.reduce { a, b -> a and b }
val total = HistoryInvoiceBasicTable.selectAll() val total = HistoryInvoiceBasicTable.selectAll()
.where(whereClause) .where(whereClause)
.count() .count()
val items = HistoryInvoiceBasicTable.selectAll() val rows = HistoryInvoiceBasicTable.selectAll()
.where(whereClause) .where(whereClause)
.orderBy(HistoryInvoiceBasicTable.createdAt, SortOrder.DESC) .orderBy(HistoryInvoiceBasicTable.createdAt, SortOrder.DESC)
.limit(pageSize) .limit(pageSize)
.offset(pageOffset(page, pageSize)) .offset(pageOffset(page, pageSize))
.map { it.toHistoryItem() }
val batchMap = rows
.map { it[HistoryInvoiceBasicTable.invoiceReqSerialNo] }
.distinct()
.takeIf { it.isNotEmpty() }
?.let { serialNos ->
(OpenInvoiceBatchItemTable innerJoin OpenInvoiceBatchTable)
.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq userId) and
(OpenInvoiceBatchItemTable.invoiceReqSerialNo inList serialNos)
}
.associate { row ->
row[OpenInvoiceBatchItemTable.invoiceReqSerialNo] to row[OpenInvoiceBatchTable.batchNo]
}
}
?: emptyMap()
val items = rows.map { row ->
val invoiceReqSerialNo = row[HistoryInvoiceBasicTable.invoiceReqSerialNo]
row.toHistoryItem(batchNo = batchMap[invoiceReqSerialNo])
}
return PageResult(items, page, pageSize, total) return PageResult(items, page, pageSize, total)
} }
@@ -215,6 +265,19 @@ object BlueInvoiceDao {
} }
} }
fun markInvoiceFailed(userId: Uuid, invoiceReqSerialNo: String, message: String?) {
val now = OffsetDateTime.now()
val errorMessage = (message ?: "开票失败").take(200)
HistoryInvoiceBasicTable.update({
(HistoryInvoiceBasicTable.userId eq userId) and
(HistoryInvoiceBasicTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}) {
it[code] = "9999"
it[msg] = errorMessage
it[updatedAt] = now
}
}
fun <T> UpdateBuilder<Int>.setIfNotNull( fun <T> UpdateBuilder<Int>.setIfNotNull(
column: Column<T>, column: Column<T>,
value: T? value: T?
@@ -542,7 +605,7 @@ object BlueInvoiceDao {
/** /**
* 将 [ResultRow] 转换为 [InvoiceHistoryItem](列表页使用) * 将 [ResultRow] 转换为 [InvoiceHistoryItem](列表页使用)
*/ */
private fun ResultRow.toHistoryItem(): InvoiceHistoryItem { private fun ResultRow.toHistoryItem(batchNo: String? = null): InvoiceHistoryItem {
val codeValue = this[HistoryInvoiceBasicTable.code] val codeValue = this[HistoryInvoiceBasicTable.code]
val status = when (codeValue) { val status = when (codeValue) {
"0000" -> "SUCCESS" "0000" -> "SUCCESS"
@@ -564,6 +627,7 @@ object BlueInvoiceDao {
return InvoiceHistoryItem( return InvoiceHistoryItem(
id = this[HistoryInvoiceBasicTable.id], id = this[HistoryInvoiceBasicTable.id],
batchNo = batchNo,
invoiceReqSerialNo = this[HistoryInvoiceBasicTable.invoiceReqSerialNo], invoiceReqSerialNo = this[HistoryInvoiceBasicTable.invoiceReqSerialNo],
taxpayerNum = this[HistoryInvoiceBasicTable.sellerTaxpayerNum], taxpayerNum = this[HistoryInvoiceBasicTable.sellerTaxpayerNum],
invoiceKindCode = this[HistoryInvoiceBasicTable.invoiceKind], invoiceKindCode = this[HistoryInvoiceBasicTable.invoiceKind],
@@ -26,6 +26,8 @@ import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi import kotlin.uuid.ExperimentalUuidApi
object LogDao { object LogDao {
private const val MAX_API_LOG_BODY_LENGTH = 20_000
fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<OperationLogItem> { fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult<OperationLogItem> {
var where: Op<Boolean> = Op.TRUE var where: Op<Boolean> = Op.TRUE
if (!keyword.isNullOrBlank()) { if (!keyword.isNullOrBlank()) {
@@ -125,4 +127,38 @@ object LogDao {
it[createdAt] = OffsetDateTime.now() it[createdAt] = OffsetDateTime.now()
} }
} }
fun saveApiAccessLog(
call: ApplicationCall,
appKey: String?,
appName: String?,
requestBody: String?,
responseCode: String?,
responseBody: String?,
status: String,
errorMessage: String?,
costMs: Long,
) {
SysApiAccessLogTable.insert {
it[traceId] = call.traceIdOrNull()
it[SysApiAccessLogTable.appKey] = appKey?.take(100)
it[SysApiAccessLogTable.appName] = appName?.take(100)
it[httpMethod] = call.request.httpMethod.value
it[requestPath] = call.request.path().take(255)
it[requestHeaders] = null
it[SysApiAccessLogTable.requestBody] = requestBody.truncateApiBody()
it[SysApiAccessLogTable.responseCode] = responseCode
it[SysApiAccessLogTable.responseBody] = responseBody.truncateApiBody()
it[ip] = call.request.local.remoteHost.take(64)
it[SysApiAccessLogTable.status] = status
it[SysApiAccessLogTable.errorMessage] = errorMessage
it[SysApiAccessLogTable.costMs] = costMs
it[createdAt] = OffsetDateTime.now()
}
}
private fun String?.truncateApiBody(): String? {
if (this == null || length <= MAX_API_LOG_BODY_LENGTH) return this
return take(MAX_API_LOG_BODY_LENGTH) + "...[truncated, originalLength=$length]"
}
} }
@@ -12,6 +12,7 @@ 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.common.PageResult import com.bbit.ticket.entity.common.PageResult
import com.bbit.ticket.entity.common.statusLabel import com.bbit.ticket.entity.common.statusLabel
import com.bbit.ticket.utils.ApiKeyUtil
import io.ktor.http.HttpStatusCode import io.ktor.http.HttpStatusCode
import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.Op
import org.jetbrains.exposed.v1.core.ResultRow import org.jetbrains.exposed.v1.core.ResultRow
@@ -80,6 +81,7 @@ object UserDao {
it[SysUserTable.avatar] = request.avatar.trimToNull() it[SysUserTable.avatar] = request.avatar.trimToNull()
it[SysUserTable.orgId] = orgId it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = request.status it[SysUserTable.status] = request.status
it[SysUserTable.apiKey] = ApiKeyUtil.generate()
it[SysUserTable.tokenVersion] = 1 it[SysUserTable.tokenVersion] = 1
it[SysUserTable.createdAt] = now it[SysUserTable.createdAt] = now
} }
@@ -221,6 +223,7 @@ object UserDao {
status = this[SysUserTable.status], status = this[SysUserTable.status],
statusLabel = statusLabel(this[SysUserTable.status]), statusLabel = statusLabel(this[SysUserTable.status]),
roleCodes = roleCodes, roleCodes = roleCodes,
apiKey = this[SysUserTable.apiKey],
) )
private fun ResultRow.toUserDetail(roleIds: List<String>) = UserDetailResponse( private fun ResultRow.toUserDetail(roleIds: List<String>) = UserDetailResponse(
@@ -235,6 +238,7 @@ object UserDao {
status = this[SysUserTable.status], status = this[SysUserTable.status],
statusLabel = statusLabel(this[SysUserTable.status]), statusLabel = statusLabel(this[SysUserTable.status]),
roleIds = roleIds, roleIds = roleIds,
apiKey = this[SysUserTable.apiKey],
taxpayerNum = this[SysUserTable.taxpayerNum], taxpayerNum = this[SysUserTable.taxpayerNum],
account = this[SysUserTable.taxAccount], account = this[SysUserTable.taxAccount],
taxPassword = this[SysUserTable.taxPassword], taxPassword = this[SysUserTable.taxPassword],
@@ -0,0 +1,47 @@
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 OpenInvoiceBatchTable : Table("open_invoice_batch") {
val id = uuid("id").clientDefault { Uuid.random() }
val userId = uuid("user_id")
val batchNo = varchar("batch_no", 64)
val totalCount = integer("total_count").default(0)
val successCount = integer("success_count").default(0)
val failedCount = integer("failed_count").default(0)
val processingCount = integer("processing_count").default(0)
val status = varchar("status", 20).default("PROCESSING")
val resolved = bool("resolved").default(false)
val createdAt = timestampWithTimeZone("created_at")
val updatedAt = timestampWithTimeZone("updated_at").nullable()
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex(userId, batchNo)
}
}
@OptIn(ExperimentalUuidApi::class)
object OpenInvoiceBatchItemTable : Table("open_invoice_batch_item") {
val id = uuid("id").clientDefault { Uuid.random() }
val batchId = uuid("batch_id").references(OpenInvoiceBatchTable.id)
val requestNo = varchar("request_no", 64).nullable()
val invoiceReqSerialNo = varchar("invoice_req_serial_no", 64)
val originalRequestBody = text("original_request_body").nullable()
val status = varchar("status", 20).default("PENDING")
val errorCode = varchar("error_code", 64).nullable()
val errorMessage = text("error_message").nullable()
val createdAt = timestampWithTimeZone("created_at")
val updatedAt = timestampWithTimeZone("updated_at").nullable()
override val primaryKey = PrimaryKey(id)
init {
uniqueIndex(batchId, invoiceReqSerialNo)
}
}
@@ -15,6 +15,7 @@ object SysUserTable : Table("sys_user") {
val avatar = text("avatar").nullable() val avatar = text("avatar").nullable()
val orgId = uuid("org_id").nullable() val orgId = uuid("org_id").nullable()
val status = varchar("status", 20).default("ENABLED") val status = varchar("status", 20).default("ENABLED")
val apiKey = varchar("api_key", 128).nullable().uniqueIndex()
val tokenVersion = integer("token_version").default(1) val tokenVersion = integer("token_version").default(1)
val lastLoginAt = timestampWithTimeZone("last_login_at").nullable() val lastLoginAt = timestampWithTimeZone("last_login_at").nullable()
val lastLoginIp = varchar("last_login_ip", 64).nullable() val lastLoginIp = varchar("last_login_ip", 64).nullable()
@@ -11,6 +11,7 @@ data class UserListItem(
val status: String, val status: String,
val statusLabel: String, val statusLabel: String,
val roleCodes: List<String>, val roleCodes: List<String>,
val apiKey: String? = null,
) )
@Serializable @Serializable
@@ -26,6 +27,7 @@ data class UserDetailResponse(
val status: String, val status: String,
val statusLabel: String, val statusLabel: String,
val roleIds: List<String>, val roleIds: List<String>,
val apiKey: String? = null,
val taxpayerNum: String? = null, val taxpayerNum: String? = null,
val account: String? = null, val account: String? = null,
val taxPassword: String? = null, val taxPassword: String? = null,
@@ -0,0 +1,92 @@
package com.bbit.ticket.entity.openapi
import com.bbit.ticket.entity.request.InvoiceItem
import com.bbit.ticket.entity.request.OrderInfo
import com.bbit.ticket.entity.request.VariableLevyProof
import kotlinx.serialization.Serializable
@Serializable
data class OpenBlueInvoiceCreateRequest(
val requestNo: String? = null,
val invoiceReqSerialNo: String,
val invoiceIssueKindCode: String = "82",
val buyerName: String,
val purchaseInvSellerIdType: String? = null,
val buyerTaxpayerNum: String? = null,
val naturalPersonFlag: String? = null,
val buyerAddress: String? = null,
val buyerTel: String? = null,
val buyerBankName: String? = null,
val buyerBankAccount: String? = null,
val showBuyerBank: String? = null,
val showSellerBank: String? = null,
val showBuyerAddrTel: String? = null,
val showSellerAddrTel: String? = null,
val variableLevyFlag: String? = null,
val casherName: String? = null,
val reviewerName: String? = null,
val takerName: String? = null,
val takerTel: String? = null,
val takerEmail: String? = null,
val specialInvoiceKind: String? = null,
val remark: String? = null,
val definedData: String? = null,
val tradeNo: String? = null,
val shopNum: String? = null,
val itemList: List<InvoiceItem>,
val variableLevyProofList: List<VariableLevyProof>? = null,
val orderList: List<OrderInfo>? = null,
)
@Serializable
data class OpenBlueInvoiceCreateResponse(
val requestNo: String? = null,
val invoiceReqSerialNo: String,
val status: String,
)
@Serializable
data class OpenBlueInvoiceQueryResponse(
val invoiceReqSerialNo: String,
val status: String,
val code: String? = null,
val message: String? = null,
val invoiceCode: String? = null,
val invoiceNo: String? = null,
val electronicInvoiceNo: String? = null,
val invoiceDate: String? = null,
val buyerName: String? = null,
val totalAmount: String? = null,
val downloadUrl: String? = null,
)
@Serializable
data class OpenBlueInvoiceSampleResponse(
val invoiceReqSerialNo: String,
val downloadUrl: String? = null,
)
@Serializable
data class OpenBlueInvoiceBatchCreateRequest(
val batchNo: String,
val items: List<OpenBlueInvoiceCreateRequest> = emptyList(),
)
@Serializable
data class OpenBlueInvoiceBatchCreateResponse(
val batchNo: String,
val status: String,
val total: Int,
)
@Serializable
data class OpenBlueInvoiceBatchQueryResponse(
val batchNo: String,
val status: String,
val total: Int,
val success: Int,
val failed: Int,
val processing: Int,
val resolved: Boolean,
val items: List<OpenBlueInvoiceQueryResponse>,
)
@@ -14,6 +14,7 @@ import kotlin.uuid.Uuid
@Serializable @Serializable
data class InvoiceHistoryItem( data class InvoiceHistoryItem(
val id: Uuid, val id: Uuid,
val batchNo: String? = null,
/** 发票请求流水号 */ /** 发票请求流水号 */
val invoiceReqSerialNo: String, val invoiceReqSerialNo: String,
@@ -0,0 +1,123 @@
@file:OptIn(ExperimentalUuidApi::class)
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.PTException
import com.bbit.ticket.entity.common.fail
import com.bbit.ticket.entity.common.ok
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest
import com.bbit.ticket.service.openapi.OpenBlueInvoiceService
import com.bbit.ticket.utils.requireOpenApiPrincipal
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.server.application.ApplicationCall
import io.ktor.server.request.receive
import io.ktor.server.response.respond
import io.ktor.server.routing.Route
import io.ktor.server.routing.get
import io.ktor.server.routing.post
import io.ktor.server.routing.route
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlin.time.TimeSource
import kotlin.uuid.ExperimentalUuidApi
fun Route.registerOpenBlueInvoiceRoutes() {
post {
val principal = call.requireOpenApiPrincipal()
val request = call.receive<OpenBlueInvoiceCreateRequest>()
call.respondOpenApi(principal.apiKey, principal.username, myJson.encodeToString(request)) {
OpenBlueInvoiceService.createSingle(principal, request)
}
}
get("/{invoiceReqSerialNo}") {
val principal = call.requireOpenApiPrincipal()
val invoiceReqSerialNo = call.parameters["invoiceReqSerialNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.querySingle(principal, invoiceReqSerialNo)
}
}
get("/sample/{invoiceReqSerialNo}") {
val principal = call.requireOpenApiPrincipal()
val invoiceReqSerialNo = call.parameters["invoiceReqSerialNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.sample(principal, invoiceReqSerialNo)
}
}
post("/batches") {
val principal = call.requireOpenApiPrincipal()
val request = call.receive<OpenBlueInvoiceBatchCreateRequest>()
call.respondOpenApi(principal.apiKey, principal.username, myJson.encodeToString(request)) {
val response = OpenBlueInvoiceService.createBatch(principal, request)
call.application.launch {
OpenBlueInvoiceService.processBatch(principal, request.batchNo)
}
response
}
}
get("/batches/{batchNo}") {
val principal = call.requireOpenApiPrincipal()
val batchNo = call.parameters["batchNo"].orEmpty()
call.respondOpenApi(principal.apiKey, principal.username, null) {
OpenBlueInvoiceService.queryBatch(principal, batchNo)
}
}
}
private suspend inline fun <reified T> ApplicationCall.respondOpenApi(
appKey: String?,
appName: String?,
requestBody: String?,
crossinline block: suspend () -> T,
) {
val start = TimeSource.Monotonic.markNow()
try {
val data = block()
val response = ok(data)
val responseBody = myJson.encodeToString(response)
respond(response)
saveOpenApiLog(appKey, appName, requestBody, response.code, responseBody, "SUCCESS", null, start)
} catch (e: PTException) {
val response = fail(code = e.code, message = e.message, traceId = e.serialNo)
respond(response)
saveOpenApiLog(appKey, appName, requestBody, e.code, myJson.encodeToString(response), "FAILED", e.message, start)
} catch (e: BizException) {
val response = fail(code = e.errorCode, message = e.message)
respond(e.status, response)
saveOpenApiLog(appKey, appName, requestBody, e.errorCode, myJson.encodeToString(response), "FAILED", e.message, start)
} catch (e: Exception) {
val response = fail(code = "-1", message = e.message ?: "开放接口调用失败")
respond(response)
saveOpenApiLog(appKey, appName, requestBody, "-1", myJson.encodeToString(response), "FAILED", e.message, start)
}
}
private suspend fun ApplicationCall.saveOpenApiLog(
appKey: String?,
appName: String?,
requestBody: String?,
responseCode: String?,
responseBody: String?,
status: String,
errorMessage: String?,
start: TimeSource.Monotonic.ValueTimeMark,
) = dbQuery {
LogDao.saveApiAccessLog(
call = this@saveOpenApiLog,
appKey = appKey,
appName = appName,
requestBody = requestBody,
responseCode = responseCode,
responseBody = responseBody,
status = status,
errorMessage = errorMessage,
costMs = start.elapsedNow().inWholeMilliseconds,
)
}
@@ -18,6 +18,7 @@ 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 com.bbit.ticket.utils.requirePtProfile
import io.ktor.server.request.receive import io.ktor.server.request.receive
import io.ktor.server.response.respond import io.ktor.server.response.respond
import io.ktor.server.routing.Route import io.ktor.server.routing.Route
@@ -30,14 +31,9 @@ fun Route.registerPTAuthRoutes() {
get("/info") { get("/info") {
try { try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val taxpayerNum = currentUser.taxPayerNum val profile = currentUser.requirePtProfile()
val account = currentUser.taxAccount
if(taxpayerNum == null || account == null){
call.respond(fail(code = "-1", message = "请先完成纳税人信息填写"))
return@get
}
val response = PTAuthService.getTaxBureauAccountAuthStatus( val response = PTAuthService.getTaxBureauAccountAuthStatus(
TaxBureauAuthReq(taxpayerNum, account) TaxBureauAuthReq(profile.taxpayerNum, profile.taxAccount)
) )
call.respond(ok(response)) call.respond(ok(response))
} catch (e: PTException) { } catch (e: PTException) {
@@ -117,7 +113,7 @@ fun Route.registerPTAuthRoutes() {
try { try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
if (currentUser.taxPayerNum == null) { if (currentUser.taxPayerNum == null) {
call.respond(fail(code = "-1", message = "请先完成企业信息填写")) call.respond(fail(code = "-1", message = "请先完善用户信息"))
} else { } else {
val response = PTConfigService.getDigitalAccount(currentUser.id) val response = PTConfigService.getDigitalAccount(currentUser.id)
if (response == null) { if (response == null) {
@@ -172,10 +168,11 @@ fun Route.registerPTAuthRoutes() {
try { try {
val qrcodeType = call.request.queryParameters["qrcodeType"] val qrcodeType = call.request.queryParameters["qrcodeType"]
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val profile = currentUser.requirePtProfile()
val response = PTAuthService.getAuthenticationQrcode( val response = PTAuthService.getAuthenticationQrcode(
AuthQrcodeRequest( AuthQrcodeRequest(
taxpayerNum = currentUser.taxPayerNum ?: "", taxpayerNum = profile.taxpayerNum,
account = currentUser.taxAccount ?: "", account = profile.taxAccount,
qrcodeType = qrcodeType qrcodeType = qrcodeType
) )
) )
@@ -12,6 +12,7 @@ import com.bbit.ticket.entity.request.RedCreateRequest
import com.bbit.ticket.service.piaotong.PTBlueService 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 io.ktor.http.ContentType import io.ktor.http.ContentType
import io.ktor.http.HttpHeaders import io.ktor.http.HttpHeaders
import io.ktor.server.request.receive import io.ktor.server.request.receive
@@ -23,7 +24,7 @@ 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.registerPTiInvoiceRoutes() { fun Route.registerPTInvoiceRoutes() {
/** /**
* 创建红票任务 * 创建红票任务
*/ */
@@ -51,7 +52,7 @@ fun Route.registerPTiInvoiceRoutes() {
try { try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
val req = call.receive<AskInvoiceRequest>() val req = call.receive<AskInvoiceRequest>()
val response = PTBlueService.invoiceBlue(req, currentUser.id) val response = PTBlueService.invoiceBlue(req, currentUser)
call.respond(ok(response)) call.respond(ok(response))
} catch (e: PTException) { } catch (e: PTException) {
call.respond( call.respond(
@@ -71,14 +72,24 @@ fun Route.registerPTiInvoiceRoutes() {
val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20 val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20
val invoiceType = call.request.queryParameters["invoiceType"] val invoiceType = call.request.queryParameters["invoiceType"]
val isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull() val isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull()
val batchNo = call.request.queryParameters["batchNo"]
val response = PTBlueService.getInvoiceBlueHistory( val response = PTBlueService.getInvoiceBlueHistory(
currentUser.id, page, pageSize, invoiceType, isSuccess currentUser.id, page, pageSize, invoiceType, isSuccess, batchNo
) )
call.respond(ok(response)) call.respond(ok(response))
} catch (e: Exception) { } catch (e: Exception) {
call.respond(fail(code = "-1", message = e.message ?: "查询开票历史失败")) call.respond(fail(code = "-1", message = e.message ?: "查询开票历史失败"))
} }
} }
get("/invoiceBatchNos") {
try {
val currentUser = call.requireCurrentUser()
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 { try {
val currentUser = call.requireCurrentUser() val currentUser = call.requireCurrentUser()
@@ -187,7 +198,7 @@ fun Route.registerPTiInvoiceRoutes() {
} }
val response = PTBlueService.queryInvoiceAllInfo( val response = PTBlueService.queryInvoiceAllInfo(
QueryInvoiceRequest( QueryInvoiceRequest(
taxpayerNum = currentUser.taxPayerNum ?: "", taxpayerNum = currentUser.requirePtProfile().taxpayerNum,
invoiceReqSerialNo = invoiceReqSerialNo invoiceReqSerialNo = invoiceReqSerialNo
) )
) )
@@ -0,0 +1,389 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.service.openapi
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchCreateResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceBatchQueryResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateRequest
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceCreateResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceQueryResponse
import com.bbit.ticket.entity.openapi.OpenBlueInvoiceSampleResponse
import com.bbit.ticket.entity.request.AskInvoiceRequest
import com.bbit.ticket.service.piaotong.PTBlueService
import com.bbit.ticket.utils.OpenApiPrincipal
import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.plugins.myJson
import io.ktor.http.HttpStatusCode
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
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.jdbc.insert
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
object OpenBlueInvoiceService {
private const val MAX_BATCH_SIZE = 100
suspend fun createSingle(
principal: OpenApiPrincipal,
request: OpenBlueInvoiceCreateRequest,
): OpenBlueInvoiceCreateResponse {
validateCreateRequest(request)
val existing = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo)
if (existing != null) {
return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo,
status = existing.status,
)
}
PTBlueService.createBlueInvoice(request.toAskInvoiceRequest(principal), principal.userId)
val detail = PTBlueService.getInvoiceDetail(principal.userId, request.invoiceReqSerialNo)
return OpenBlueInvoiceCreateResponse(
requestNo = request.requestNo,
invoiceReqSerialNo = request.invoiceReqSerialNo,
status = detail?.status ?: "PROCESSING",
)
}
suspend fun querySingle(principal: OpenApiPrincipal, invoiceReqSerialNo: String): OpenBlueInvoiceQueryResponse {
if (invoiceReqSerialNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "invoiceReqSerialNo 不能为空")
}
runCatching {
PTBlueService.syncInvoiceFromPT(principal.userId, invoiceReqSerialNo, principal.taxPayerNum)
}
val detail = PTBlueService.getInvoiceDetail(principal.userId, invoiceReqSerialNo)
?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该发票记录", HttpStatusCode.NotFound)
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, invoiceReqSerialNo)?.downloadUrl
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
code = detail.code,
message = detail.msg,
invoiceCode = detail.invoiceCode,
invoiceNo = detail.invoiceNo,
electronicInvoiceNo = detail.electronicInvoiceNo,
invoiceDate = detail.issuedAt,
buyerName = detail.buyerName,
totalAmount = detail.totalAmount,
downloadUrl = downloadUrl,
)
}
suspend fun sample(principal: OpenApiPrincipal, invoiceReqSerialNo: String): OpenBlueInvoiceSampleResponse {
val detail = PTBlueService.getInvoiceDetail(principal.userId, invoiceReqSerialNo)
?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该发票记录", HttpStatusCode.NotFound)
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, detail.invoiceReqSerialNo)?.downloadUrl
return OpenBlueInvoiceSampleResponse(detail.invoiceReqSerialNo, downloadUrl)
}
suspend fun createBatch(
principal: OpenApiPrincipal,
request: OpenBlueInvoiceBatchCreateRequest,
): OpenBlueInvoiceBatchCreateResponse {
validateBatchCreateRequest(request)
val exists = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq request.batchNo)
}
.singleOrNull() != null
}
if (exists) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批次号已存在,一个批次只能开票一次")
}
val now = OffsetDateTime.now()
dbQuery {
val batchId = OpenInvoiceBatchTable.insert {
it[userId] = principal.userId
it[batchNo] = request.batchNo
it[totalCount] = request.items.size
it[successCount] = 0
it[failedCount] = 0
it[processingCount] = request.items.size
it[status] = "PROCESSING"
it[resolved] = false
it[createdAt] = now
}[OpenInvoiceBatchTable.id]
request.items.forEach { item ->
OpenInvoiceBatchItemTable.insert {
it[OpenInvoiceBatchItemTable.batchId] = batchId
it[requestNo] = item.requestNo
it[invoiceReqSerialNo] = item.invoiceReqSerialNo
it[originalRequestBody] = myJson.encodeToString(item)
it[status] = "PENDING"
it[createdAt] = now
}
}
}
return OpenBlueInvoiceBatchCreateResponse(
batchNo = request.batchNo,
status = "PROCESSING",
total = request.items.size,
)
}
suspend fun processBatch(principal: OpenApiPrincipal, batchNo: String) {
val batch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq batchNo)
}
.singleOrNull()
} ?: return
val batchId = batch[OpenInvoiceBatchTable.id]
val items = dbQuery {
OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.orderBy(OpenInvoiceBatchItemTable.createdAt, SortOrder.ASC)
.map { itemRow ->
val body = itemRow[OpenInvoiceBatchItemTable.originalRequestBody]
?: throw BizException(ErrorCode.INTERNAL_SERVER_ERROR.code, "缺少原始请求体")
itemRow[OpenInvoiceBatchItemTable.invoiceReqSerialNo] to
myJson.decodeFromString<OpenBlueInvoiceCreateRequest>(body)
}
}
items.forEach { (invoiceReqSerialNo, request) ->
updateBatchItem(batchId, invoiceReqSerialNo, "PROCESSING", null, null)
runCatching {
createSingle(principal, request)
}.onSuccess { response ->
updateBatchItem(batchId, invoiceReqSerialNo, response.status, null, null)
}.onFailure { error ->
val code = (error as? BizException)?.errorCode
?: (error as? com.bbit.ticket.entity.common.PTException)?.code
?: ErrorCode.INTERNAL_SERVER_ERROR.code
updateBatchItem(batchId, invoiceReqSerialNo, "FAILED", code, error.message)
}
refreshBatchSummary(batchId)
}
refreshBatchSummary(batchId)
}
suspend fun queryBatch(principal: OpenApiPrincipal, batchNo: String): OpenBlueInvoiceBatchQueryResponse {
val batch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where {
(OpenInvoiceBatchTable.userId eq principal.userId) and
(OpenInvoiceBatchTable.batchNo eq batchNo)
}
.singleOrNull()
} ?: throw BizException(ErrorCode.BAD_REQUEST.code, "未找到该批次", HttpStatusCode.NotFound)
val batchId = batch[OpenInvoiceBatchTable.id]
val itemSnapshots = dbQuery {
OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.orderBy(OpenInvoiceBatchItemTable.createdAt, SortOrder.ASC)
.map { itemRow ->
BatchItemSnapshot(
invoiceReqSerialNo = itemRow[OpenInvoiceBatchItemTable.invoiceReqSerialNo],
status = itemRow[OpenInvoiceBatchItemTable.status],
errorCode = itemRow[OpenInvoiceBatchItemTable.errorCode],
errorMessage = itemRow[OpenInvoiceBatchItemTable.errorMessage],
)
}
}
val items = itemSnapshots.map { buildBatchInvoiceQueryResponse(principal, batchId, it) }
refreshBatchSummary(batchId)
val refreshedBatch = dbQuery {
OpenInvoiceBatchTable.selectAll()
.where { OpenInvoiceBatchTable.id eq batchId }
.single()
}
return OpenBlueInvoiceBatchQueryResponse(
batchNo = refreshedBatch[OpenInvoiceBatchTable.batchNo],
status = refreshedBatch[OpenInvoiceBatchTable.status],
total = refreshedBatch[OpenInvoiceBatchTable.totalCount],
success = refreshedBatch[OpenInvoiceBatchTable.successCount],
failed = refreshedBatch[OpenInvoiceBatchTable.failedCount],
processing = refreshedBatch[OpenInvoiceBatchTable.processingCount],
resolved = refreshedBatch[OpenInvoiceBatchTable.resolved],
items = items,
)
}
private suspend fun buildBatchInvoiceQueryResponse(
principal: OpenApiPrincipal,
batchId: Uuid,
item: BatchItemSnapshot,
): OpenBlueInvoiceQueryResponse {
if (item.status != "PENDING") {
runCatching {
PTBlueService.syncInvoiceFromPT(
principal.userId,
item.invoiceReqSerialNo,
principal.taxPayerNum,
)
}
}
val detail = PTBlueService.getInvoiceDetail(principal.userId, item.invoiceReqSerialNo)
if (detail == null) {
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = item.invoiceReqSerialNo,
status = item.status,
code = item.errorCode,
message = item.errorMessage,
)
}
val downloadUrl = PTBlueService.getInvoiceDownloadUrl(principal.userId, detail.invoiceReqSerialNo)?.downloadUrl
updateBatchItem(
batchId = batchId,
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
errorCode = if (detail.status == "FAILED") detail.code else null,
errorMessage = if (detail.status == "FAILED") detail.msg else null,
)
return OpenBlueInvoiceQueryResponse(
invoiceReqSerialNo = detail.invoiceReqSerialNo,
status = detail.status,
code = detail.code,
message = detail.msg,
invoiceCode = detail.invoiceCode,
invoiceNo = detail.invoiceNo,
electronicInvoiceNo = detail.electronicInvoiceNo,
invoiceDate = detail.issuedAt,
buyerName = detail.buyerName,
totalAmount = detail.totalAmount,
downloadUrl = downloadUrl,
)
}
private suspend fun updateBatchItem(
batchId: Uuid,
invoiceReqSerialNo: String,
status: String,
errorCode: String?,
errorMessage: String?,
) = dbQuery {
OpenInvoiceBatchItemTable.update({
(OpenInvoiceBatchItemTable.batchId eq batchId) and
(OpenInvoiceBatchItemTable.invoiceReqSerialNo eq invoiceReqSerialNo)
}) {
it[OpenInvoiceBatchItemTable.status] = status
it[OpenInvoiceBatchItemTable.errorCode] = errorCode
it[OpenInvoiceBatchItemTable.errorMessage] = errorMessage
it[updatedAt] = OffsetDateTime.now()
}
}
private suspend fun refreshBatchSummary(batchId: Uuid) = dbQuery {
val statuses = OpenInvoiceBatchItemTable.selectAll()
.where { OpenInvoiceBatchItemTable.batchId eq batchId }
.map { it[OpenInvoiceBatchItemTable.status] }
val success = statuses.count { it == "SUCCESS" }
val failed = statuses.count { it == "FAILED" }
val processing = statuses.size - success - failed
val status = when {
processing > 0 -> "PROCESSING"
failed > 0 -> if (success > 0) "PARTIAL_FAILED" else "FAILED"
else -> "SUCCESS"
}
OpenInvoiceBatchTable.update({ OpenInvoiceBatchTable.id eq batchId }) {
it[successCount] = success
it[failedCount] = failed
it[processingCount] = processing
it[OpenInvoiceBatchTable.status] = status
it[resolved] = status == "SUCCESS"
it[updatedAt] = OffsetDateTime.now()
}
}
private fun validateBatchCreateRequest(request: OpenBlueInvoiceBatchCreateRequest) {
if (request.batchNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "batchNo 不能为空")
}
if (request.items.isEmpty()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "批量明细不能为空")
}
if (request.items.size > MAX_BATCH_SIZE) {
throw BizException(ErrorCode.BAD_REQUEST.code, "单次批量最多支持 $MAX_BATCH_SIZE 条")
}
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) {
if (request.invoiceReqSerialNo.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "invoiceReqSerialNo 不能为空")
}
if (request.buyerName.isBlank()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "buyerName 不能为空")
}
if (request.itemList.isEmpty()) {
throw BizException(ErrorCode.BAD_REQUEST.code, "itemList 不能为空")
}
}
private fun OpenBlueInvoiceCreateRequest.toAskInvoiceRequest(principal: OpenApiPrincipal): AskInvoiceRequest =
AskInvoiceRequest(
taxpayerNum = principal.taxPayerNum,
invoiceReqSerialNo = invoiceReqSerialNo,
invoiceIssueKindCode = invoiceIssueKindCode,
buyerName = buyerName,
purchaseInvSellerIdType = purchaseInvSellerIdType,
buyerTaxpayerNum = buyerTaxpayerNum,
naturalPersonFlag = naturalPersonFlag,
buyerAddress = buyerAddress,
buyerTel = buyerTel,
buyerBankName = buyerBankName,
buyerBankAccount = buyerBankAccount,
showBuyerBank = showBuyerBank,
showSellerBank = showSellerBank,
showBuyerAddrTel = showBuyerAddrTel,
showSellerAddrTel = showSellerAddrTel,
account = principal.taxAccount,
variableLevyFlag = variableLevyFlag,
casherName = casherName,
reviewerName = reviewerName,
takerName = takerName,
takerTel = takerTel,
takerEmail = takerEmail,
specialInvoiceKind = specialInvoiceKind,
remark = remark,
definedData = definedData,
tradeNo = tradeNo ?: requestNo,
shopNum = shopNum,
itemList = itemList,
variableLevyProofList = variableLevyProofList,
orderList = orderList,
)
private data class BatchItemSnapshot(
val invoiceReqSerialNo: String,
val status: String,
val errorCode: String?,
val errorMessage: String?,
)
}
@@ -12,6 +12,8 @@ import com.bbit.ticket.entity.response.QueryInvoiceResult
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.InvoiceDetailResponse
import com.bbit.ticket.entity.response.InvoiceHistoryItem import com.bbit.ticket.entity.response.InvoiceHistoryItem
import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.requirePtProfile
import io.ktor.client.request.get import io.ktor.client.request.get
import io.ktor.client.statement.bodyAsBytes import io.ktor.client.statement.bodyAsBytes
import com.bbit.ticket.utils.plugins.dbQuery import com.bbit.ticket.utils.plugins.dbQuery
@@ -20,6 +22,9 @@ import kotlin.uuid.Uuid
object PTBlueService { object PTBlueService {
suspend fun listBatchNos(userId: Uuid): List<String> =
dbQuery { BlueInvoiceDao.listBatchNos(userId) }
/** /**
* 查询票通同步发票信息(支持插入和更新) * 查询票通同步发票信息(支持插入和更新)
* *
@@ -45,9 +50,25 @@ object PTBlueService {
/** /**
* 蓝票接口调用 * 蓝票接口调用
*/ */
suspend fun invoiceBlue(req: AskInvoiceRequest, userId: Uuid): String { suspend fun invoiceBlue(req: AskInvoiceRequest, user: CurrentUser): String {
PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>("invoiceBlue.pt", req) val profile = user.requirePtProfile()
return createBlueInvoice(
req.copy(
taxpayerNum = profile.taxpayerNum,
account = req.account ?: profile.taxAccount,
),
user.id,
)
}
suspend fun createBlueInvoice(req: AskInvoiceRequest, userId: Uuid): String {
dbQuery { BlueInvoiceDao.addInvoice(userId, req) } dbQuery { BlueInvoiceDao.addInvoice(userId, req) }
try {
PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>("invoiceBlue.pt", req)
} catch (e: Exception) {
dbQuery { BlueInvoiceDao.markInvoiceFailed(userId, req.invoiceReqSerialNo, e.message) }
throw e
}
// 创建后立即同步一次(非关键,失败忽略) // 创建后立即同步一次(非关键,失败忽略)
try { try {
syncInvoiceFromPT(userId, req.invoiceReqSerialNo, req.taxpayerNum) syncInvoiceFromPT(userId, req.invoiceReqSerialNo, req.taxpayerNum)
@@ -63,9 +84,10 @@ object PTBlueService {
page: Int, page: Int,
pageSize: Int, pageSize: Int,
invoiceType: String? = null, invoiceType: String? = null,
isSuccess: Boolean? = null isSuccess: Boolean? = null,
batchNo: String? = null,
): PageResult<InvoiceHistoryItem> = ): PageResult<InvoiceHistoryItem> =
dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess) } dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess, batchNo) }
/** /**
* 查询发票完整详情 * 查询发票完整详情
@@ -10,6 +10,7 @@ import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse
import com.bbit.ticket.entity.response.QuickRedInvoiceResponse import com.bbit.ticket.entity.response.QuickRedInvoiceResponse
import com.bbit.ticket.entity.response.RedInvoiceInfoResponse import com.bbit.ticket.entity.response.RedInvoiceInfoResponse
import com.bbit.ticket.utils.CurrentUser import com.bbit.ticket.utils.CurrentUser
import com.bbit.ticket.utils.requirePtProfile
import com.bbit.ticket.utils.plugins.dbQuery import com.bbit.ticket.utils.plugins.dbQuery
import com.bbit.ticket.utils.net.PTClient import com.bbit.ticket.utils.net.PTClient
import io.ktor.client.request.get import io.ktor.client.request.get
@@ -27,13 +28,14 @@ object PTRedService {
* 红票接口调用 * 红票接口调用
*/ */
suspend fun invoiceRed(user: CurrentUser,req:RedCreateRequest): String { suspend fun invoiceRed(user: CurrentUser,req:RedCreateRequest): String {
val profile = user.requirePtProfile()
val invoiceReqSerialNo = PTClient.ptDate() val invoiceReqSerialNo = PTClient.ptDate()
val historyId = Uuid.parse(req.historyId) val historyId = Uuid.parse(req.historyId)
val his = dbQuery { val his = dbQuery {
HistoryDao.findByHistory(historyId, user.id) HistoryDao.findByHistory(historyId, user.id)
} }
val req = QuickRedInvoiceRequest( val req = QuickRedInvoiceRequest(
taxpayerNum = user.taxPayerNum!!, taxpayerNum = profile.taxpayerNum,
invoiceReqSerialNo = invoiceReqSerialNo, invoiceReqSerialNo = invoiceReqSerialNo,
invoiceCode = his.invoiceCode, invoiceCode = his.invoiceCode,
invoiceNo = his.invoiceNo, invoiceNo = his.invoiceNo,
@@ -41,7 +43,7 @@ object PTRedService {
blueInvoiceDate = his.blueInvoiceDate, blueInvoiceDate = his.blueInvoiceDate,
redReason = req.redReason, redReason = req.redReason,
amount = his.totalAmount?.negate()?.toPlainString()?:"0.0", amount = his.totalAmount?.negate()?.toPlainString()?:"0.0",
account = user.taxAccount, account = profile.taxAccount,
invoiceKind = his.invoiceKind, invoiceKind = his.invoiceKind,
takerName = req.takerName, takerName = req.takerName,
takerTel = req.takerTel, takerTel = req.takerTel,
@@ -51,7 +53,7 @@ object PTRedService {
dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) } dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) }
// 创建后立即同步一次(非关键,失败忽略) // 创建后立即同步一次(非关键,失败忽略)
try { try {
PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, user.taxPayerNum!!) PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, profile.taxpayerNum)
} catch (_: Exception) { } } catch (_: Exception) { }
return "操作成功" return "操作成功"
} }
@@ -0,0 +1,14 @@
package com.bbit.ticket.utils
import java.security.SecureRandom
import java.util.Base64
object ApiKeyUtil {
private val random = SecureRandom()
fun generate(): String {
val bytes = ByteArray(32)
random.nextBytes(bytes)
return "tk_" + Base64.getUrlEncoder().withoutPadding().encodeToString(bytes)
}
}
@@ -0,0 +1,78 @@
@file:OptIn(ExperimentalUuidApi::class)
package com.bbit.ticket.utils
import com.bbit.ticket.database.system.SysUserTable
import com.bbit.ticket.entity.common.BizException
import com.bbit.ticket.entity.common.ErrorCode
import com.bbit.ticket.utils.plugins.dbQuery
import io.ktor.http.HttpStatusCode
import io.ktor.server.application.ApplicationCall
import org.jetbrains.exposed.v1.core.and
import org.jetbrains.exposed.v1.core.eq
import org.jetbrains.exposed.v1.core.isNull
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.update
import java.time.OffsetDateTime
import kotlin.uuid.ExperimentalUuidApi
import kotlin.uuid.Uuid
data class OpenApiPrincipal(
val userId: Uuid,
val username: String,
val apiKey: String,
val taxPayerNum: String,
val taxAccount: String,
)
suspend fun ApplicationCall.requireOpenApiPrincipal(): OpenApiPrincipal {
val apiKey = request.headers["X-Api-Key"]?.trim()
?: throw BizException(ErrorCode.UNAUTHORIZED.code, "缺少 X-Api-Key", HttpStatusCode.Unauthorized)
if (apiKey.isBlank()) {
throw BizException(ErrorCode.UNAUTHORIZED.code, "缺少 X-Api-Key", HttpStatusCode.Unauthorized)
}
val row = dbQuery {
SysUserTable.selectAll()
.where {
(SysUserTable.apiKey eq apiKey) and
(SysUserTable.status eq "ENABLED") and
SysUserTable.deletedAt.isNull()
}
.singleOrNull()
} ?: throw BizException(ErrorCode.UNAUTHORIZED.code, "API Key 无效或已停用", HttpStatusCode.Unauthorized)
val taxpayerNum = row[SysUserTable.taxpayerNum]?.takeIf { it.isNotBlank() }
val taxAccount = row[SysUserTable.taxAccount]?.takeIf { it.isNotBlank() }
if (taxpayerNum == null || taxAccount == null) {
throw BizException(ErrorCode.BAD_REQUEST.code, "请先完善用户信息")
}
dbQuery {
SysUserTable.update({ SysUserTable.id eq row[SysUserTable.id] }) {
it[updatedAt] = OffsetDateTime.now()
}
}
return OpenApiPrincipal(
userId = row[SysUserTable.id],
username = row[SysUserTable.username],
apiKey = apiKey,
taxPayerNum = taxpayerNum,
taxAccount = taxAccount,
)
}
data class PtProfile(
val taxpayerNum: String,
val taxAccount: String,
)
fun CurrentUser.requirePtProfile(): PtProfile {
val taxpayerNum = taxPayerNum?.takeIf { it.isNotBlank() }
val account = taxAccount?.takeIf { it.isNotBlank() }
if (taxpayerNum == null || account == null) {
throw BizException(ErrorCode.BAD_REQUEST.code, "请先完善用户信息")
}
return PtProfile(taxpayerNum, account)
}
@@ -5,6 +5,8 @@ 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.HistoryInvoiceVoucherTable import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable
import com.bbit.ticket.database.piaotong.HistoryInvoiceRedTable import com.bbit.ticket.database.piaotong.HistoryInvoiceRedTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable
import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable
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
@@ -39,6 +41,8 @@ object DatabaseInitializer {
HistoryInvoiceGoodsTable, HistoryInvoiceGoodsTable,
HistoryInvoiceVoucherTable, HistoryInvoiceVoucherTable,
HistoryInvoiceOrderTable, HistoryInvoiceOrderTable,
OpenInvoiceBatchTable,
OpenInvoiceBatchItemTable,
) )
// 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。 // 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。
transaction { transaction {
@@ -25,6 +25,7 @@ object SeedData {
const val ADMIN_USERNAME = "admin" const val ADMIN_USERNAME = "admin"
const val ADMIN_INIT_PASSWORD = "Admin@123456" const val ADMIN_INIT_PASSWORD = "Admin@123456"
const val ADMIN_INIT_API_KEY = "tk_admin_test_key_please_change"
private const val DEFAULT_ORG_CODE = "DEFAULT_ORG" private const val DEFAULT_ORG_CODE = "DEFAULT_ORG"
private const val SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN" private const val SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN"
@@ -119,6 +120,9 @@ object SeedData {
it[SysUserTable.nickname] = "系统管理员" it[SysUserTable.nickname] = "系统管理员"
it[SysUserTable.orgId] = orgId it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = "ENABLED" it[SysUserTable.status] = "ENABLED"
if (existing[SysUserTable.apiKey].isNullOrBlank()) {
it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY
}
it[SysUserTable.updatedAt] = now it[SysUserTable.updatedAt] = now
it[SysUserTable.taxpayerNum] = "500102201007206608" it[SysUserTable.taxpayerNum] = "500102201007206608"
it[SysUserTable.phone] = "13000000000" it[SysUserTable.phone] = "13000000000"
@@ -135,6 +139,7 @@ object SeedData {
it[SysUserTable.nickname] = "系统管理员" it[SysUserTable.nickname] = "系统管理员"
it[SysUserTable.orgId] = orgId it[SysUserTable.orgId] = orgId
it[SysUserTable.status] = "ENABLED" it[SysUserTable.status] = "ENABLED"
it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY
it[SysUserTable.tokenVersion] = 1 it[SysUserTable.tokenVersion] = 1
it[SysUserTable.taxpayerNum] = "500102201007206608" it[SysUserTable.taxpayerNum] = "500102201007206608"
it[SysUserTable.phone] = "13000000000" it[SysUserTable.phone] = "13000000000"
+6 -1
View File
@@ -391,6 +391,7 @@ export interface PageResult<T> {
/** 发票历史记录 */ /** 发票历史记录 */
export interface InvoiceHistoryItem { export interface InvoiceHistoryItem {
id: string id: string
batchNo?: string
/** 发票请求流水号 */ /** 发票请求流水号 */
invoiceReqSerialNo: string invoiceReqSerialNo: string
/** 销方税号 */ /** 销方税号 */
@@ -558,11 +559,15 @@ export const invoiceStatusColorMap: Record<string, string> = {
export function invoiceHistoryApi( export function invoiceHistoryApi(
page: number, page: number,
pageSize: number, pageSize: number,
params?: { invoiceType?: string; isSuccess?: boolean } params?: { invoiceType?: string; isSuccess?: boolean; batchNo?: string }
): Promise<PageResult<InvoiceHistoryItem>> { ): Promise<PageResult<InvoiceHistoryItem>> {
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } }) return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } })
} }
export function invoiceBatchNosApi(): Promise<string[]> {
return http.get('/pt/invoiceBatchNos')
}
// ============================================= // =============================================
// 发票详情 // 发票详情
// ============================================= // =============================================
@@ -22,6 +22,14 @@
</button> </button>
</div> </div>
<div class="surface-filters"> <div class="surface-filters">
<n-select
v-model:value="selectedBatchNo"
:options="batchNoOptions"
clearable
placeholder="批次号"
style="width: 180px"
@update:value="handleFilterChange"
/>
<n-select <n-select
v-model:value="selectedStatus" v-model:value="selectedStatus"
:options="statusFilterOptions" :options="statusFilterOptions"
@@ -481,6 +489,7 @@ import {
RotateCcw RotateCcw
} from 'lucide-vue-next' } from 'lucide-vue-next'
import { import {
invoiceBatchNosApi,
invoiceDownloadUrlApi, invoiceDownloadUrlApi,
invoicePreviewBlobApi, invoicePreviewBlobApi,
invoiceDetailApi, invoiceDetailApi,
@@ -566,6 +575,8 @@ function statusTagType(status: string): 'warning' | 'info' | 'success' | 'error'
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 batchNoOptions = ref<Array<{ label: string; value: string }>>([])
const statusFilterOptions = [ const statusFilterOptions = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
@@ -579,12 +590,17 @@ const TAB_FILTERS: Record<string, { invoiceType: string }> = {
} }
function getFilterParams() { function getFilterParams() {
const params: { invoiceType: string; isSuccess?: boolean } = { ...TAB_FILTERS[activeTab.value] } const params: { invoiceType: string; isSuccess?: boolean; batchNo?: string } = {
...TAB_FILTERS[activeTab.value]
}
if (selectedStatus.value === 'SUCCESS') { if (selectedStatus.value === 'SUCCESS') {
params.isSuccess = true params.isSuccess = true
} else if (selectedStatus.value === 'NOT_SUCCESS') { } else if (selectedStatus.value === 'NOT_SUCCESS') {
params.isSuccess = false params.isSuccess = false
} }
if (selectedBatchNo.value) {
params.batchNo = selectedBatchNo.value
}
return params return params
} }
@@ -605,10 +621,20 @@ function handleFilterChange() {
function handleReset() { function handleReset() {
selectedStatus.value = null selectedStatus.value = null
selectedBatchNo.value = null
pagination.page = 1 pagination.page = 1
fetchData() fetchData()
} }
async function fetchBatchNoOptions() {
try {
const res = await invoiceBatchNosApi()
batchNoOptions.value = res.map((batchNo) => ({ label: batchNo, value: batchNo }))
} catch {
batchNoOptions.value = []
}
}
const message = useMessage() const message = useMessage()
const loading = ref(false) const loading = ref(false)
const dataSource = ref<InvoiceHistoryItem[]>([]) const dataSource = ref<InvoiceHistoryItem[]>([])
@@ -749,6 +775,16 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
row.invoiceReqSerialNo row.invoiceReqSerialNo
) )
}, },
{
title: '批次',
key: 'batchNo',
width: 180,
ellipsis: { tooltip: true },
render: (row: InvoiceHistoryItem) =>
row.batchNo
? h(NTag, { size: 'small', round: true, type: 'info' }, () => row.batchNo)
: h('span', { style: 'color:#9ca3af' }, '-')
},
{ {
title: '状态', title: '状态',
key: 'status', key: 'status',
@@ -1078,6 +1114,7 @@ const voucherColumns: DataTableColumns<InvoiceDetailVoucher> = [
] ]
onMounted(() => { onMounted(() => {
fetchBatchNoOptions()
fetchData() fetchData()
}) })
</script> </script>
+13
View File
@@ -76,6 +76,9 @@
<n-form-item label="邮箱" path="email"> <n-form-item label="邮箱" path="email">
<n-input v-model:value="editForm.email" /> <n-input v-model:value="editForm.email" />
</n-form-item> </n-form-item>
<n-form-item v-if="editModal.mode === 'edit'" label="API Key">
<n-input v-model:value="editForm.apiKey" readonly />
</n-form-item>
<n-form-item v-if="editModal.mode === 'create'" label="状态" path="status"> <n-form-item v-if="editModal.mode === 'create'" label="状态" path="status">
<n-radio-group v-model:value="editForm.status"> <n-radio-group v-model:value="editForm.status">
<n-radio-button value="ENABLED">启用</n-radio-button> <n-radio-button value="ENABLED">启用</n-radio-button>
@@ -237,6 +240,7 @@ const editForm = reactive({
realName: '', realName: '',
phone: '', phone: '',
email: '', email: '',
apiKey: '',
status: 'ENABLED' status: 'ENABLED'
}) })
@@ -357,6 +361,7 @@ function resetEditForm() {
editForm.realName = '' editForm.realName = ''
editForm.phone = '' editForm.phone = ''
editForm.email = '' editForm.email = ''
editForm.apiKey = ''
editForm.status = 'ENABLED' editForm.status = 'ENABLED'
} }
@@ -379,6 +384,7 @@ async function openEdit(row: UserListItem) {
editForm.realName = detail.realName ?? '' editForm.realName = detail.realName ?? ''
editForm.phone = detail.phone ?? '' editForm.phone = detail.phone ?? ''
editForm.email = detail.email ?? '' editForm.email = detail.email ?? ''
editForm.apiKey = detail.apiKey ?? ''
editForm.status = detail.status editForm.status = detail.status
editModal.visible = true editModal.visible = true
} }
@@ -445,6 +451,13 @@ const columns = computed<DataTableColumns<UserListItem>>(() => [
minWidth: 180, minWidth: 180,
render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-') render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-')
}, },
{
title: 'API Key',
key: 'apiKey',
minWidth: 220,
ellipsis: { tooltip: true },
render: (row) => row.apiKey || '-'
},
{ {
title: '状态', title: '状态',
key: 'status', key: 'status',
+2
View File
@@ -9,6 +9,7 @@ export interface UserListItem {
status: string status: string
statusLabel?: string statusLabel?: string
roleCodes: string[] roleCodes: string[]
apiKey?: string | null
} }
export interface UserDetail { export interface UserDetail {
@@ -23,6 +24,7 @@ export interface UserDetail {
status: string status: string
statusLabel?: string statusLabel?: string
roleIds: string[] roleIds: string[]
apiKey?: string | null
taxpayerNum?: string | null taxpayerNum?: string | null
account?: string | null account?: string | null
taxPassword?: string | null taxPassword?: string | null