diff --git a/apipost-open-blue-invoice-tests.json b/apipost-open-blue-invoice-tests.json new file mode 100644 index 0000000..27df6ec --- /dev/null +++ b/apipost-open-blue-invoice-tests.json @@ -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", "蓝票"] + } +] diff --git a/server/src/main/kotlin/com/bbit/ticket/Application.kt b/server/src/main/kotlin/com/bbit/ticket/Application.kt index 91ad97b..2a60d6a 100644 --- a/server/src/main/kotlin/com/bbit/ticket/Application.kt +++ b/server/src/main/kotlin/com/bbit/ticket/Application.kt @@ -15,7 +15,8 @@ import com.bbit.ticket.utils.plugins.configureSerialization import com.bbit.ticket.utils.plugins.configureStatusPages import com.bbit.ticket.utils.plugins.configureTrace 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.registerLogsQueryRoutes import com.bbit.ticket.route.system.registerMenuRoutes @@ -64,11 +65,18 @@ fun Application.module() { registerMenuRoutes() registerDictRoutes() registerLogsQueryRoutes() - + route("/open/v1") { + route("/blue-invoices") { + registerOpenBlueInvoiceRoutes() + } + route("/f8") { + + } + } route("/pt") { authenticate("auth-jwt") { registerPTAuthRoutes() - registerPTiInvoiceRoutes() + registerPTInvoiceRoutes() } } } diff --git a/server/src/main/kotlin/com/bbit/ticket/dao/piaotong/BlueInvoiceDao.kt b/server/src/main/kotlin/com/bbit/ticket/dao/piaotong/BlueInvoiceDao.kt index c98350c..1bd5da4 100644 --- a/server/src/main/kotlin/com/bbit/ticket/dao/piaotong/BlueInvoiceDao.kt +++ b/server/src/main/kotlin/com/bbit/ticket/dao/piaotong/BlueInvoiceDao.kt @@ -7,6 +7,8 @@ import com.bbit.ticket.database.piaotong.HistoryInvoiceBasicTable import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable +import com.bbit.ticket.database.piaotong.OpenInvoiceBatchItemTable +import com.bbit.ticket.database.piaotong.OpenInvoiceBatchTable import com.bbit.ticket.entity.common.PageResult import com.bbit.ticket.entity.request.AskInvoiceRequest 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.and 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.like import org.jetbrains.exposed.v1.core.neq import org.jetbrains.exposed.v1.core.statements.UpdateBuilder import org.jetbrains.exposed.v1.jdbc.deleteWhere @@ -41,6 +45,12 @@ import kotlin.uuid.Uuid object BlueInvoiceDao { + fun listBatchNos(userId: Uuid): List = + OpenInvoiceBatchTable.selectAll() + .where { OpenInvoiceBatchTable.userId eq userId } + .orderBy(OpenInvoiceBatchTable.createdAt, SortOrder.DESC) + .map { it[OpenInvoiceBatchTable.batchNo] } + // ============================================= // 开票历史查询 // ============================================= @@ -53,7 +63,8 @@ object BlueInvoiceDao { page: Int, pageSize: Int, invoiceType: String? = null, - isSuccess: Boolean? = null + isSuccess: Boolean? = null, + batchNo: String? = null, ): PageResult { val conditions = mutableListOf>() 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 total = HistoryInvoiceBasicTable.selectAll() .where(whereClause) .count() - val items = HistoryInvoiceBasicTable.selectAll() + val rows = HistoryInvoiceBasicTable.selectAll() .where(whereClause) .orderBy(HistoryInvoiceBasicTable.createdAt, SortOrder.DESC) .limit(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) } @@ -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 UpdateBuilder.setIfNotNull( column: Column, value: T? @@ -542,7 +605,7 @@ object BlueInvoiceDao { /** * 将 [ResultRow] 转换为 [InvoiceHistoryItem](列表页使用) */ - private fun ResultRow.toHistoryItem(): InvoiceHistoryItem { + private fun ResultRow.toHistoryItem(batchNo: String? = null): InvoiceHistoryItem { val codeValue = this[HistoryInvoiceBasicTable.code] val status = when (codeValue) { "0000" -> "SUCCESS" @@ -564,6 +627,7 @@ object BlueInvoiceDao { return InvoiceHistoryItem( id = this[HistoryInvoiceBasicTable.id], + batchNo = batchNo, invoiceReqSerialNo = this[HistoryInvoiceBasicTable.invoiceReqSerialNo], taxpayerNum = this[HistoryInvoiceBasicTable.sellerTaxpayerNum], invoiceKindCode = this[HistoryInvoiceBasicTable.invoiceKind], diff --git a/server/src/main/kotlin/com/bbit/ticket/dao/system/LogDao.kt b/server/src/main/kotlin/com/bbit/ticket/dao/system/LogDao.kt index 9a18194..9ac9011 100644 --- a/server/src/main/kotlin/com/bbit/ticket/dao/system/LogDao.kt +++ b/server/src/main/kotlin/com/bbit/ticket/dao/system/LogDao.kt @@ -26,6 +26,8 @@ import java.time.OffsetDateTime import kotlin.uuid.ExperimentalUuidApi object LogDao { + private const val MAX_API_LOG_BODY_LENGTH = 20_000 + fun operationLogs(page: Int, pageSize: Int, keyword: String?, status: String?): PageResult { var where: Op = Op.TRUE if (!keyword.isNullOrBlank()) { @@ -125,4 +127,38 @@ object LogDao { 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]" + } } diff --git a/server/src/main/kotlin/com/bbit/ticket/dao/system/UserDao.kt b/server/src/main/kotlin/com/bbit/ticket/dao/system/UserDao.kt index d357ed3..42f81ee 100644 --- a/server/src/main/kotlin/com/bbit/ticket/dao/system/UserDao.kt +++ b/server/src/main/kotlin/com/bbit/ticket/dao/system/UserDao.kt @@ -12,6 +12,7 @@ import com.bbit.ticket.entity.common.BizException import com.bbit.ticket.entity.common.ErrorCode import com.bbit.ticket.entity.common.PageResult import com.bbit.ticket.entity.common.statusLabel +import com.bbit.ticket.utils.ApiKeyUtil import io.ktor.http.HttpStatusCode import org.jetbrains.exposed.v1.core.Op import org.jetbrains.exposed.v1.core.ResultRow @@ -80,6 +81,7 @@ object UserDao { it[SysUserTable.avatar] = request.avatar.trimToNull() it[SysUserTable.orgId] = orgId it[SysUserTable.status] = request.status + it[SysUserTable.apiKey] = ApiKeyUtil.generate() it[SysUserTable.tokenVersion] = 1 it[SysUserTable.createdAt] = now } @@ -221,6 +223,7 @@ object UserDao { status = this[SysUserTable.status], statusLabel = statusLabel(this[SysUserTable.status]), roleCodes = roleCodes, + apiKey = this[SysUserTable.apiKey], ) private fun ResultRow.toUserDetail(roleIds: List) = UserDetailResponse( @@ -235,6 +238,7 @@ object UserDao { status = this[SysUserTable.status], statusLabel = statusLabel(this[SysUserTable.status]), roleIds = roleIds, + apiKey = this[SysUserTable.apiKey], taxpayerNum = this[SysUserTable.taxpayerNum], account = this[SysUserTable.taxAccount], taxPassword = this[SysUserTable.taxPassword], diff --git a/server/src/main/kotlin/com/bbit/ticket/database/piaotong/OpenInvoiceBatchTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/piaotong/OpenInvoiceBatchTable.kt new file mode 100644 index 0000000..710574a --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/database/piaotong/OpenInvoiceBatchTable.kt @@ -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) + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt index 53ac1f1..2cb54fd 100644 --- a/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt +++ b/server/src/main/kotlin/com/bbit/ticket/database/system/SysUserTable.kt @@ -15,6 +15,7 @@ object SysUserTable : Table("sys_user") { val avatar = text("avatar").nullable() val orgId = uuid("org_id").nullable() val status = varchar("status", 20).default("ENABLED") + val apiKey = varchar("api_key", 128).nullable().uniqueIndex() val tokenVersion = integer("token_version").default(1) val lastLoginAt = timestampWithTimeZone("last_login_at").nullable() val lastLoginIp = varchar("last_login_ip", 64).nullable() diff --git a/server/src/main/kotlin/com/bbit/ticket/entity/common/system/UserDto.kt b/server/src/main/kotlin/com/bbit/ticket/entity/common/system/UserDto.kt index 7f74402..758db50 100644 --- a/server/src/main/kotlin/com/bbit/ticket/entity/common/system/UserDto.kt +++ b/server/src/main/kotlin/com/bbit/ticket/entity/common/system/UserDto.kt @@ -11,6 +11,7 @@ data class UserListItem( val status: String, val statusLabel: String, val roleCodes: List, + val apiKey: String? = null, ) @Serializable @@ -26,6 +27,7 @@ data class UserDetailResponse( val status: String, val statusLabel: String, val roleIds: List, + val apiKey: String? = null, val taxpayerNum: String? = null, val account: String? = null, val taxPassword: String? = null, diff --git a/server/src/main/kotlin/com/bbit/ticket/entity/openapi/OpenBlueInvoiceDto.kt b/server/src/main/kotlin/com/bbit/ticket/entity/openapi/OpenBlueInvoiceDto.kt new file mode 100644 index 0000000..5ab95dc --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/entity/openapi/OpenBlueInvoiceDto.kt @@ -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, + val variableLevyProofList: List? = null, + val orderList: List? = 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 = 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, +) diff --git a/server/src/main/kotlin/com/bbit/ticket/entity/response/InvoiceHistoryItem.kt b/server/src/main/kotlin/com/bbit/ticket/entity/response/InvoiceHistoryItem.kt index d1061c7..84e3cbd 100644 --- a/server/src/main/kotlin/com/bbit/ticket/entity/response/InvoiceHistoryItem.kt +++ b/server/src/main/kotlin/com/bbit/ticket/entity/response/InvoiceHistoryItem.kt @@ -14,6 +14,7 @@ import kotlin.uuid.Uuid @Serializable data class InvoiceHistoryItem( val id: Uuid, + val batchNo: String? = null, /** 发票请求流水号 */ val invoiceReqSerialNo: String, diff --git a/server/src/main/kotlin/com/bbit/ticket/route/openapi/registerOpenBlueInvoiceRoutes.kt b/server/src/main/kotlin/com/bbit/ticket/route/openapi/registerOpenBlueInvoiceRoutes.kt new file mode 100644 index 0000000..612372e --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/route/openapi/registerOpenBlueInvoiceRoutes.kt @@ -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() + 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() + 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 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, + ) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTAuthRoutes.kt b/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTAuthRoutes.kt index 9fc8be2..8595b24 100644 --- a/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTAuthRoutes.kt +++ b/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTAuthRoutes.kt @@ -18,6 +18,7 @@ import com.bbit.ticket.entity.request.UpdatePresetDataRequest import com.bbit.ticket.service.piaotong.PTAuthService import com.bbit.ticket.service.piaotong.PTConfigService import com.bbit.ticket.utils.requireCurrentUser +import com.bbit.ticket.utils.requirePtProfile import io.ktor.server.request.receive import io.ktor.server.response.respond import io.ktor.server.routing.Route @@ -30,14 +31,9 @@ fun Route.registerPTAuthRoutes() { get("/info") { try { val currentUser = call.requireCurrentUser() - val taxpayerNum = currentUser.taxPayerNum - val account = currentUser.taxAccount - if(taxpayerNum == null || account == null){ - call.respond(fail(code = "-1", message = "请先完成纳税人信息填写")) - return@get - } + val profile = currentUser.requirePtProfile() val response = PTAuthService.getTaxBureauAccountAuthStatus( - TaxBureauAuthReq(taxpayerNum, account) + TaxBureauAuthReq(profile.taxpayerNum, profile.taxAccount) ) call.respond(ok(response)) } catch (e: PTException) { @@ -117,7 +113,7 @@ fun Route.registerPTAuthRoutes() { try { val currentUser = call.requireCurrentUser() if (currentUser.taxPayerNum == null) { - call.respond(fail(code = "-1", message = "请先完成企业信息填写")) + call.respond(fail(code = "-1", message = "请先完善用户信息")) } else { val response = PTConfigService.getDigitalAccount(currentUser.id) if (response == null) { @@ -172,10 +168,11 @@ fun Route.registerPTAuthRoutes() { try { val qrcodeType = call.request.queryParameters["qrcodeType"] val currentUser = call.requireCurrentUser() + val profile = currentUser.requirePtProfile() val response = PTAuthService.getAuthenticationQrcode( AuthQrcodeRequest( - taxpayerNum = currentUser.taxPayerNum ?: "", - account = currentUser.taxAccount ?: "", + taxpayerNum = profile.taxpayerNum, + account = profile.taxAccount, qrcodeType = qrcodeType ) ) diff --git a/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTiInvoiceRoutes.kt b/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTInvoiceRoutes.kt similarity index 93% rename from server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTiInvoiceRoutes.kt rename to server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTInvoiceRoutes.kt index e245c95..d5657ba 100644 --- a/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTiInvoiceRoutes.kt +++ b/server/src/main/kotlin/com/bbit/ticket/route/piaotong/registerPTInvoiceRoutes.kt @@ -12,6 +12,7 @@ import com.bbit.ticket.entity.request.RedCreateRequest import com.bbit.ticket.service.piaotong.PTBlueService import com.bbit.ticket.service.piaotong.PTRedService import com.bbit.ticket.utils.requireCurrentUser +import com.bbit.ticket.utils.requirePtProfile import io.ktor.http.ContentType import io.ktor.http.HttpHeaders import io.ktor.server.request.receive @@ -23,7 +24,7 @@ import io.ktor.server.routing.get import io.ktor.server.routing.post import kotlin.uuid.ExperimentalUuidApi -fun Route.registerPTiInvoiceRoutes() { +fun Route.registerPTInvoiceRoutes() { /** * 创建红票任务 */ @@ -51,7 +52,7 @@ fun Route.registerPTiInvoiceRoutes() { try { val currentUser = call.requireCurrentUser() val req = call.receive() - val response = PTBlueService.invoiceBlue(req, currentUser.id) + val response = PTBlueService.invoiceBlue(req, currentUser) call.respond(ok(response)) } catch (e: PTException) { call.respond( @@ -71,14 +72,24 @@ fun Route.registerPTiInvoiceRoutes() { val pageSize = call.request.queryParameters["pageSize"]?.toIntOrNull() ?: 20 val invoiceType = call.request.queryParameters["invoiceType"] val isSuccess = call.request.queryParameters["isSuccess"]?.toBooleanStrictOrNull() + val batchNo = call.request.queryParameters["batchNo"] val response = PTBlueService.getInvoiceBlueHistory( - currentUser.id, page, pageSize, invoiceType, isSuccess + currentUser.id, page, pageSize, invoiceType, isSuccess, batchNo ) call.respond(ok(response)) } catch (e: Exception) { 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") { try { val currentUser = call.requireCurrentUser() @@ -187,7 +198,7 @@ fun Route.registerPTiInvoiceRoutes() { } val response = PTBlueService.queryInvoiceAllInfo( QueryInvoiceRequest( - taxpayerNum = currentUser.taxPayerNum ?: "", + taxpayerNum = currentUser.requirePtProfile().taxpayerNum, invoiceReqSerialNo = invoiceReqSerialNo ) ) diff --git a/server/src/main/kotlin/com/bbit/ticket/service/openapi/OpenBlueInvoiceService.kt b/server/src/main/kotlin/com/bbit/ticket/service/openapi/OpenBlueInvoiceService.kt new file mode 100644 index 0000000..31732bd --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/service/openapi/OpenBlueInvoiceService.kt @@ -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(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?, + ) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTBlueService.kt b/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTBlueService.kt index ea2efb8..012fc76 100644 --- a/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTBlueService.kt +++ b/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTBlueService.kt @@ -12,6 +12,8 @@ import com.bbit.ticket.entity.response.QueryInvoiceResult import com.bbit.ticket.entity.response.InvoiceCreateResponse import com.bbit.ticket.entity.response.InvoiceDetailResponse import com.bbit.ticket.entity.response.InvoiceHistoryItem +import com.bbit.ticket.utils.CurrentUser +import com.bbit.ticket.utils.requirePtProfile import io.ktor.client.request.get import io.ktor.client.statement.bodyAsBytes import com.bbit.ticket.utils.plugins.dbQuery @@ -20,6 +22,9 @@ import kotlin.uuid.Uuid object PTBlueService { + suspend fun listBatchNos(userId: Uuid): List = + dbQuery { BlueInvoiceDao.listBatchNos(userId) } + /** * 查询票通同步发票信息(支持插入和更新) * @@ -45,9 +50,25 @@ object PTBlueService { /** * 蓝票接口调用 */ - suspend fun invoiceBlue(req: AskInvoiceRequest, userId: Uuid): String { - PTClient.ptPost("invoiceBlue.pt", req) + suspend fun invoiceBlue(req: AskInvoiceRequest, user: CurrentUser): String { + 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) } + try { + PTClient.ptPost("invoiceBlue.pt", req) + } catch (e: Exception) { + dbQuery { BlueInvoiceDao.markInvoiceFailed(userId, req.invoiceReqSerialNo, e.message) } + throw e + } // 创建后立即同步一次(非关键,失败忽略) try { syncInvoiceFromPT(userId, req.invoiceReqSerialNo, req.taxpayerNum) @@ -63,9 +84,10 @@ object PTBlueService { page: Int, pageSize: Int, invoiceType: String? = null, - isSuccess: Boolean? = null + isSuccess: Boolean? = null, + batchNo: String? = null, ): PageResult = - dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess) } + dbQuery { BlueInvoiceDao.invoiceHistory(userId, page, pageSize, invoiceType, isSuccess, batchNo) } /** * 查询发票完整详情 diff --git a/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTRedService.kt b/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTRedService.kt index 4a56603..fc5c23e 100644 --- a/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTRedService.kt +++ b/server/src/main/kotlin/com/bbit/ticket/service/piaotong/PTRedService.kt @@ -10,6 +10,7 @@ import com.bbit.ticket.entity.response.InvoiceDownloadUrlResponse import com.bbit.ticket.entity.response.QuickRedInvoiceResponse import com.bbit.ticket.entity.response.RedInvoiceInfoResponse import com.bbit.ticket.utils.CurrentUser +import com.bbit.ticket.utils.requirePtProfile import com.bbit.ticket.utils.plugins.dbQuery import com.bbit.ticket.utils.net.PTClient import io.ktor.client.request.get @@ -27,13 +28,14 @@ object PTRedService { * 红票接口调用 */ suspend fun invoiceRed(user: CurrentUser,req:RedCreateRequest): String { + val profile = user.requirePtProfile() val invoiceReqSerialNo = PTClient.ptDate() val historyId = Uuid.parse(req.historyId) val his = dbQuery { HistoryDao.findByHistory(historyId, user.id) } val req = QuickRedInvoiceRequest( - taxpayerNum = user.taxPayerNum!!, + taxpayerNum = profile.taxpayerNum, invoiceReqSerialNo = invoiceReqSerialNo, invoiceCode = his.invoiceCode, invoiceNo = his.invoiceNo, @@ -41,7 +43,7 @@ object PTRedService { blueInvoiceDate = his.blueInvoiceDate, redReason = req.redReason, amount = his.totalAmount?.negate()?.toPlainString()?:"0.0", - account = user.taxAccount, + account = profile.taxAccount, invoiceKind = his.invoiceKind, takerName = req.takerName, takerTel = req.takerTel, @@ -51,7 +53,7 @@ object PTRedService { dbQuery { RedInvoiceDao.addRedInvoice(user.id, historyId, req) } // 创建后立即同步一次(非关键,失败忽略) try { - PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, user.taxPayerNum!!) + PTBlueService.syncInvoiceFromPT(user.id, invoiceReqSerialNo, profile.taxpayerNum) } catch (_: Exception) { } return "操作成功" } diff --git a/server/src/main/kotlin/com/bbit/ticket/utils/ApiKeyUtil.kt b/server/src/main/kotlin/com/bbit/ticket/utils/ApiKeyUtil.kt new file mode 100644 index 0000000..6608611 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/utils/ApiKeyUtil.kt @@ -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) + } +} diff --git a/server/src/main/kotlin/com/bbit/ticket/utils/OpenApiPrincipal.kt b/server/src/main/kotlin/com/bbit/ticket/utils/OpenApiPrincipal.kt new file mode 100644 index 0000000..38f5db0 --- /dev/null +++ b/server/src/main/kotlin/com/bbit/ticket/utils/OpenApiPrincipal.kt @@ -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) +} diff --git a/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/DatabaseInitializer.kt b/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/DatabaseInitializer.kt index 5d40355..7f0670b 100644 --- a/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/DatabaseInitializer.kt +++ b/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/DatabaseInitializer.kt @@ -5,6 +5,8 @@ import com.bbit.ticket.database.piaotong.HistoryInvoiceGoodsTable import com.bbit.ticket.database.piaotong.HistoryInvoiceOrderTable import com.bbit.ticket.database.piaotong.HistoryInvoiceVoucherTable import com.bbit.ticket.database.piaotong.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.SysDictItemTable import com.bbit.ticket.database.system.SysDictTypeTable @@ -39,6 +41,8 @@ object DatabaseInitializer { HistoryInvoiceGoodsTable, HistoryInvoiceVoucherTable, HistoryInvoiceOrderTable, + OpenInvoiceBatchTable, + OpenInvoiceBatchItemTable, ) // 先通过 Exposed 生成迁移 SQL,再逐条执行,避免启动时静默跳过缺失表或字段。 transaction { diff --git a/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/SeedData.kt b/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/SeedData.kt index be29a52..38d61b9 100644 --- a/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/SeedData.kt +++ b/server/src/main/kotlin/com/bbit/ticket/utils/bootstrap/SeedData.kt @@ -25,6 +25,7 @@ object SeedData { const val ADMIN_USERNAME = "admin" 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 SUPER_ADMIN_ROLE_CODE = "SUPER_ADMIN" @@ -119,6 +120,9 @@ object SeedData { it[SysUserTable.nickname] = "系统管理员" it[SysUserTable.orgId] = orgId it[SysUserTable.status] = "ENABLED" + if (existing[SysUserTable.apiKey].isNullOrBlank()) { + it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY + } it[SysUserTable.updatedAt] = now it[SysUserTable.taxpayerNum] = "500102201007206608" it[SysUserTable.phone] = "13000000000" @@ -135,6 +139,7 @@ object SeedData { it[SysUserTable.nickname] = "系统管理员" it[SysUserTable.orgId] = orgId it[SysUserTable.status] = "ENABLED" + it[SysUserTable.apiKey] = ADMIN_INIT_API_KEY it[SysUserTable.tokenVersion] = 1 it[SysUserTable.taxpayerNum] = "500102201007206608" it[SysUserTable.phone] = "13000000000" diff --git a/web/src/api/piaotong/index.ts b/web/src/api/piaotong/index.ts index 1f9159a..82475aa 100644 --- a/web/src/api/piaotong/index.ts +++ b/web/src/api/piaotong/index.ts @@ -391,6 +391,7 @@ export interface PageResult { /** 发票历史记录 */ export interface InvoiceHistoryItem { id: string + batchNo?: string /** 发票请求流水号 */ invoiceReqSerialNo: string /** 销方税号 */ @@ -558,11 +559,15 @@ export const invoiceStatusColorMap: Record = { export function invoiceHistoryApi( page: number, pageSize: number, - params?: { invoiceType?: string; isSuccess?: boolean } + params?: { invoiceType?: string; isSuccess?: boolean; batchNo?: string } ): Promise> { return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } }) } +export function invoiceBatchNosApi(): Promise { + return http.get('/pt/invoiceBatchNos') +} + // ============================================= // 发票详情 // ============================================= diff --git a/web/src/features/piaotong/invoice-history/index.vue b/web/src/features/piaotong/invoice-history/index.vue index 580531a..e8d1856 100644 --- a/web/src/features/piaotong/invoice-history/index.vue +++ b/web/src/features/piaotong/invoice-history/index.vue @@ -22,6 +22,14 @@
+ (null) +const selectedBatchNo = ref(null) +const batchNoOptions = ref>([]) const statusFilterOptions = [ { label: '全部', value: '' }, @@ -579,12 +590,17 @@ const TAB_FILTERS: Record = { } 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') { params.isSuccess = true } else if (selectedStatus.value === 'NOT_SUCCESS') { params.isSuccess = false } + if (selectedBatchNo.value) { + params.batchNo = selectedBatchNo.value + } return params } @@ -605,10 +621,20 @@ function handleFilterChange() { function handleReset() { selectedStatus.value = null + selectedBatchNo.value = null pagination.page = 1 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 loading = ref(false) const dataSource = ref([]) @@ -749,6 +775,16 @@ const columns = computed>(() => { 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: '状态', key: 'status', @@ -1078,6 +1114,7 @@ const voucherColumns: DataTableColumns = [ ] onMounted(() => { + fetchBatchNoOptions() fetchData() }) diff --git a/web/src/features/system/users/index.vue b/web/src/features/system/users/index.vue index 744c456..15692f6 100644 --- a/web/src/features/system/users/index.vue +++ b/web/src/features/system/users/index.vue @@ -76,6 +76,9 @@ + + + 启用 @@ -237,6 +240,7 @@ const editForm = reactive({ realName: '', phone: '', email: '', + apiKey: '', status: 'ENABLED' }) @@ -357,6 +361,7 @@ function resetEditForm() { editForm.realName = '' editForm.phone = '' editForm.email = '' + editForm.apiKey = '' editForm.status = 'ENABLED' } @@ -379,6 +384,7 @@ async function openEdit(row: UserListItem) { editForm.realName = detail.realName ?? '' editForm.phone = detail.phone ?? '' editForm.email = detail.email ?? '' + editForm.apiKey = detail.apiKey ?? '' editForm.status = detail.status editModal.visible = true } @@ -445,6 +451,13 @@ const columns = computed>(() => [ minWidth: 180, render: (row) => (row.roleCodes.length > 0 ? row.roleCodes.join(', ') : '-') }, + { + title: 'API Key', + key: 'apiKey', + minWidth: 220, + ellipsis: { tooltip: true }, + render: (row) => row.apiKey || '-' + }, { title: '状态', key: 'status', diff --git a/web/src/types/system/user.ts b/web/src/types/system/user.ts index 81708a1..f92d7f6 100644 --- a/web/src/types/system/user.ts +++ b/web/src/types/system/user.ts @@ -9,6 +9,7 @@ export interface UserListItem { status: string statusLabel?: string roleCodes: string[] + apiKey?: string | null } export interface UserDetail { @@ -23,6 +24,7 @@ export interface UserDetail { status: string statusLabel?: string roleIds: string[] + apiKey?: string | null taxpayerNum?: string | null account?: string | null taxPassword?: string | null