通用开票/查询接口
This commit is contained in:
@@ -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.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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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,
|
||||
pageSize: Int,
|
||||
invoiceType: String? = null,
|
||||
isSuccess: Boolean? = null
|
||||
isSuccess: Boolean? = null,
|
||||
batchNo: String? = null,
|
||||
): PageResult<InvoiceHistoryItem> {
|
||||
val conditions = mutableListOf<Op<Boolean>>()
|
||||
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 <T> UpdateBuilder<Int>.setIfNotNull(
|
||||
column: Column<T>,
|
||||
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],
|
||||
|
||||
@@ -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<OperationLogItem> {
|
||||
var where: Op<Boolean> = 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]"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) = 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],
|
||||
|
||||
@@ -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 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()
|
||||
|
||||
@@ -11,6 +11,7 @@ data class UserListItem(
|
||||
val status: String,
|
||||
val statusLabel: String,
|
||||
val roleCodes: List<String>,
|
||||
val apiKey: String? = null,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
@@ -26,6 +27,7 @@ data class UserDetailResponse(
|
||||
val status: String,
|
||||
val statusLabel: String,
|
||||
val roleIds: List<String>,
|
||||
val apiKey: String? = null,
|
||||
val taxpayerNum: String? = null,
|
||||
val account: 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
|
||||
data class InvoiceHistoryItem(
|
||||
val id: Uuid,
|
||||
val batchNo: String? = null,
|
||||
|
||||
/** 发票请求流水号 */
|
||||
val invoiceReqSerialNo: String,
|
||||
|
||||
+123
@@ -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.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
|
||||
)
|
||||
)
|
||||
|
||||
+15
-4
@@ -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<AskInvoiceRequest>()
|
||||
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
|
||||
)
|
||||
)
|
||||
@@ -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.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<String> =
|
||||
dbQuery { BlueInvoiceDao.listBatchNos(userId) }
|
||||
|
||||
/**
|
||||
* 查询票通同步发票信息(支持插入和更新)
|
||||
*
|
||||
@@ -45,9 +50,25 @@ object PTBlueService {
|
||||
/**
|
||||
* 蓝票接口调用
|
||||
*/
|
||||
suspend fun invoiceBlue(req: AskInvoiceRequest, userId: Uuid): String {
|
||||
PTClient.ptPost<AskInvoiceRequest, InvoiceCreateResponse>("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<AskInvoiceRequest, InvoiceCreateResponse>("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<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.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 "操作成功"
|
||||
}
|
||||
|
||||
@@ -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.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 {
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -391,6 +391,7 @@ export interface PageResult<T> {
|
||||
/** 发票历史记录 */
|
||||
export interface InvoiceHistoryItem {
|
||||
id: string
|
||||
batchNo?: string
|
||||
/** 发票请求流水号 */
|
||||
invoiceReqSerialNo: string
|
||||
/** 销方税号 */
|
||||
@@ -558,11 +559,15 @@ export const invoiceStatusColorMap: Record<string, string> = {
|
||||
export function invoiceHistoryApi(
|
||||
page: number,
|
||||
pageSize: number,
|
||||
params?: { invoiceType?: string; isSuccess?: boolean }
|
||||
params?: { invoiceType?: string; isSuccess?: boolean; batchNo?: string }
|
||||
): Promise<PageResult<InvoiceHistoryItem>> {
|
||||
return http.get('/pt/invoiceBlueHistory', { params: { page, pageSize, ...params } })
|
||||
}
|
||||
|
||||
export function invoiceBatchNosApi(): Promise<string[]> {
|
||||
return http.get('/pt/invoiceBatchNos')
|
||||
}
|
||||
|
||||
// =============================================
|
||||
// 发票详情
|
||||
// =============================================
|
||||
|
||||
@@ -22,6 +22,14 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="surface-filters">
|
||||
<n-select
|
||||
v-model:value="selectedBatchNo"
|
||||
:options="batchNoOptions"
|
||||
clearable
|
||||
placeholder="批次号"
|
||||
style="width: 180px"
|
||||
@update:value="handleFilterChange"
|
||||
/>
|
||||
<n-select
|
||||
v-model:value="selectedStatus"
|
||||
:options="statusFilterOptions"
|
||||
@@ -481,6 +489,7 @@ import {
|
||||
RotateCcw
|
||||
} from 'lucide-vue-next'
|
||||
import {
|
||||
invoiceBatchNosApi,
|
||||
invoiceDownloadUrlApi,
|
||||
invoicePreviewBlobApi,
|
||||
invoiceDetailApi,
|
||||
@@ -566,6 +575,8 @@ function statusTagType(status: string): 'warning' | 'info' | 'success' | 'error'
|
||||
|
||||
const activeTab = ref('BLUE')
|
||||
const selectedStatus = ref<string | null>(null)
|
||||
const selectedBatchNo = ref<string | null>(null)
|
||||
const batchNoOptions = ref<Array<{ label: string; value: string }>>([])
|
||||
|
||||
const statusFilterOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
@@ -579,12 +590,17 @@ const TAB_FILTERS: Record<string, { invoiceType: string }> = {
|
||||
}
|
||||
|
||||
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<InvoiceHistoryItem[]>([])
|
||||
@@ -749,6 +775,16 @@ const columns = computed<DataTableColumns<InvoiceHistoryItem>>(() => {
|
||||
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<InvoiceDetailVoucher> = [
|
||||
]
|
||||
|
||||
onMounted(() => {
|
||||
fetchBatchNoOptions()
|
||||
fetchData()
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -76,6 +76,9 @@
|
||||
<n-form-item label="邮箱" path="email">
|
||||
<n-input v-model:value="editForm.email" />
|
||||
</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-radio-group v-model:value="editForm.status">
|
||||
<n-radio-button value="ENABLED">启用</n-radio-button>
|
||||
@@ -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<DataTableColumns<UserListItem>>(() => [
|
||||
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',
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user