开放溯源接口

This commit is contained in:
BBIT-Kai
2026-07-08 10:20:46 +08:00
parent 73c47bee88
commit c4e24a29da
6 changed files with 543 additions and 1 deletions
@@ -1,6 +1,7 @@
package ink.snowflake.server.controller
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
@@ -19,6 +20,7 @@ import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import ink.snowflake.server.utils.dao.TraceabilityDao
import ink.snowflake.server.utils.dao.TraceabilityPublicBatchError
import io.ktor.http.ContentType
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.*
@@ -89,6 +91,23 @@ private suspend fun handleUpdateBatchStep(call: io.ktor.server.application.Appli
call.respond(BaseResponse(data = data))
}
private suspend fun handleCreatePublicBatch(call: io.ktor.server.application.ApplicationCall) {
val request = call.receive<CreateTraceabilityPublicBatchRequest>()
val result = TraceabilityDao.createPublicBatch(request)
val data = result.data
if (data != null) {
call.respond(BaseResponse(message = result.message.ifBlank { "批次已创建并发布" }, data = data))
return
}
val statusCode = when (result.error) {
TraceabilityPublicBatchError.NOT_FOUND -> HttpStatusCode.NotFound
TraceabilityPublicBatchError.CONFLICT -> HttpStatusCode.Conflict
else -> HttpStatusCode.BadRequest
}
call.respond(statusCode, BaseResponse(status = false, message = result.message.ifBlank { "批次创建失败" }, data = null))
}
fun Application.Traceability(config: AppConfig) {
TraceabilityDao.init(config)
TraceabilityDao.initSchema()
@@ -318,6 +337,24 @@ fun Application.Traceability(config: AppConfig) {
}
}
route("/public") {
route("/integration") {
post("/batches") {
handleCreatePublicBatch(call)
}
get("/batches/{id}/public-url") {
val id = parseUuidOrNull(call.parameters["id"])
if (id == null) {
call.respond(HttpStatusCode.BadRequest, BaseResponse(status = false, message = "批次ID无效", data = null))
return@get
}
val data = TraceabilityDao.getPublicBatchUrl(id)
if (data == null) {
call.respond(HttpStatusCode.NotFound, BaseResponse(status = false, message = "批次不存在", data = null))
return@get
}
call.respond(BaseResponse(data = data))
}
}
get("/by-code/{code}") {
val code = call.parameters["code"] ?: ""
val increaseScan = call.request.queryParameters["increaseScan"] == "true"
@@ -93,6 +93,30 @@ data class CreateTraceBatchRequest(
val tags: List<String> = emptyList(),
)
@Serializable
data class CreateTraceabilityPublicBatchRequest(
val templateId: String,
val batchName: String,
val batchCode: String,
val productName: String = "",
val summary: String = "",
val coverImage: String = "",
val tags: List<String> = emptyList(),
val overwrite: Boolean = false,
val operatorName: String = "",
val completedAt: String? = null,
val steps: List<TraceabilityPublicBatchStepRequest> = emptyList(),
)
@Serializable
data class TraceabilityPublicBatchStepRequest(
val templateNodeId: String? = null,
val nodeName: String? = null,
val operatorName: String = "",
val completedAt: String? = null,
val values: JsonObject = JsonObject(emptyMap()),
)
@Serializable
data class UpdateTraceBatchBaseRequest(
val batchName: String,
@@ -195,6 +195,16 @@ data class TraceBatchDetailResponse(
val publishedAt: String = "",
)
@Serializable
data class TraceabilityPublicBatchResponse(
val batchId: String,
val batchCode: String,
val publicUrl: String,
val status: String,
val publishedAt: String = "",
val overwritten: Boolean = false,
)
@Serializable
data class TraceabilityFeedbackResponse(
val id: String,
@@ -10,11 +10,13 @@ import ink.snowflake.server.model.database.TraceabilityPreviewPagesTable
import ink.snowflake.server.model.database.TraceabilityTemplateNodesTable
import ink.snowflake.server.model.database.TraceabilityTemplatesTable
import ink.snowflake.server.model.request.CreateTraceBatchRequest
import ink.snowflake.server.model.request.CreateTraceabilityPublicBatchRequest
import ink.snowflake.server.model.request.SaveTraceTemplateRequest
import ink.snowflake.server.model.request.SaveTraceNodeLibraryRequest
import ink.snowflake.server.model.request.SaveTracePreviewPageRequest
import ink.snowflake.server.model.request.SubmitTraceabilityFeedbackRequest
import ink.snowflake.server.model.request.TraceFieldDefinitionRequest
import ink.snowflake.server.model.request.TraceabilityPublicBatchStepRequest
import ink.snowflake.server.model.request.TracePreviewNodeRequest
import ink.snowflake.server.model.request.UpdateTraceBatchBaseRequest
import ink.snowflake.server.model.request.UpdateTraceBatchStepRequest
@@ -34,18 +36,21 @@ import ink.snowflake.server.model.response.TraceTemplateSummaryResponse
import ink.snowflake.server.model.response.TraceabilityFeedbackResponse
import ink.snowflake.server.model.response.TraceabilityFileAssetResponse
import ink.snowflake.server.model.response.TraceabilityOverviewResponse
import ink.snowflake.server.model.response.TraceabilityPublicBatchResponse
import ink.snowflake.server.model.response.TraceabilityPublicDetailResponse
import ink.snowflake.server.utils.AppConfig
import ink.snowflake.server.utils.OSSUtils
import kotlinx.datetime.Clock
import kotlinx.datetime.Instant
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNull
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.put
import org.jetbrains.exposed.v1.core.SortOrder
@@ -58,15 +63,48 @@ import org.jetbrains.exposed.v1.jdbc.insertAndGetId
import org.jetbrains.exposed.v1.jdbc.selectAll
import org.jetbrains.exposed.v1.jdbc.transactions.transaction
import org.jetbrains.exposed.v1.jdbc.update
import java.time.LocalDate
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.UUID
enum class TraceabilityPublicBatchError {
BAD_REQUEST,
NOT_FOUND,
CONFLICT,
}
data class TraceabilityPublicBatchOperationResult(
val data: TraceabilityPublicBatchResponse? = null,
val message: String = "",
val error: TraceabilityPublicBatchError? = null,
)
object TraceabilityDao {
private val json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
}
private val dateTimeFormatter: DateTimeFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")
private var publicPreviewBaseUrl: String = "http://127.0.0.1:8081"
private data class PreparedPublicBatchStep(
val node: TraceTemplateNodeResponse,
val values: JsonObject,
val operatorName: String,
val completedAt: Instant,
)
private data class PreparedPublicBatchStepsResult(
val steps: List<PreparedPublicBatchStep> = emptyList(),
val error: TraceabilityPublicBatchOperationResult? = null,
)
private data class FieldNormalizationResult(
val value: JsonElement? = null,
val error: String = "",
)
fun init(config: AppConfig) {
publicPreviewBaseUrl = config.traceabilityPublicPreviewBaseUrl
}
@@ -705,6 +743,111 @@ object TraceabilityDao {
getBatch(batchId)
}
fun createPublicBatch(request: CreateTraceabilityPublicBatchRequest): TraceabilityPublicBatchOperationResult = transaction {
val templateId = runCatching { UUID.fromString(request.templateId) }.getOrNull()
?: return@transaction publicBatchBadRequest("模板ID无效")
val template = getTemplate(templateId)
?: return@transaction publicBatchNotFound("模板不存在")
if (template.status != "active") {
return@transaction publicBatchBadRequest("模板未发布,请先发布模板后再通过接口创建批次")
}
val batchCode = request.batchCode.trim()
val batchName = request.batchName.trim()
if (batchCode.isBlank()) {
return@transaction publicBatchBadRequest("batchCode 不能为空")
}
if (batchName.isBlank()) {
return@transaction publicBatchBadRequest("batchName 不能为空")
}
val nowInstant = nowInstant()
val preparedSteps = preparePublicBatchSteps(template, request, nowInstant)
if (preparedSteps.error != null) {
return@transaction preparedSteps.error
}
val steps = preparedSteps.steps
val existingBatch = TraceabilityBatchesTable.selectAll()
.where { TraceabilityBatchesTable.batchCode eq batchCode }
.singleOrNull()
val overwritten = existingBatch != null
if (existingBatch != null && !request.overwrite) {
return@transaction TraceabilityPublicBatchOperationResult(
message = "批次编码已存在,如需覆盖请传 overwrite=true",
error = TraceabilityPublicBatchError.CONFLICT,
)
}
val now = timestampLiteral(nowInstant)
val tagsJson = json.encodeToString(request.tags.map { it.trim() }.filter { it.isNotBlank() })
val batchId = if (existingBatch == null) {
TraceabilityBatchesTable.insertAndGetId {
it[this.templateId] = templateId
it[this.batchName] = batchName
it[this.batchCode] = batchCode
it[productName] = request.productName.trim()
it[summary] = request.summary
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
it[this.tagsJson] = tagsJson
it[status] = "published"
it[currentStep] = (steps.size - 1).coerceAtLeast(0)
it[publishedAt] = now
it[createdAt] = now
it[updatedAt] = now
}.value
} else {
val currentBatchId = existingBatch[TraceabilityBatchesTable.id].value
TraceabilityBatchStepsTable.deleteWhere { TraceabilityBatchStepsTable.batchId eq currentBatchId }
TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq currentBatchId }) {
it[this.templateId] = templateId
it[this.batchName] = batchName
it[this.batchCode] = batchCode
it[productName] = request.productName.trim()
it[summary] = request.summary
it[coverImage] = request.coverImage.ifBlank { template.coverImage }
it[this.tagsJson] = tagsJson
it[status] = "published"
it[currentStep] = (steps.size - 1).coerceAtLeast(0)
it[publishedAt] = now
it[updatedAt] = now
}
currentBatchId
}
steps.forEach { step ->
TraceabilityBatchStepsTable.insertAndGetId {
it[this.batchId] = batchId
it[this.templateNodeId] = UUID.fromString(step.node.id)
it[sort] = step.node.sort
it[category] = step.node.category
it[name] = step.node.name
it[description] = step.node.description
it[locked] = step.node.locked
it[consumerVisible] = step.node.consumerVisible
it[fieldsJson] = json.encodeToString(step.node.fields)
it[status] = "completed"
it[operatorName] = step.operatorName
it[valuesJson] = json.encodeToString(step.values)
it[completedAt] = timestampLiteral(step.completedAt)
it[createdAt] = now
it[updatedAt] = now
}
}
val batch = getBatch(batchId)
?: return@transaction publicBatchNotFound("批次创建失败")
TraceabilityPublicBatchOperationResult(
message = if (overwritten) "批次已覆盖并发布" else "批次已创建并发布",
data = batch.toPublicBatchResponse(overwritten),
)
}
fun getPublicBatchUrl(batchId: UUID): TraceabilityPublicBatchResponse? = transaction {
getBatch(batchId)?.toPublicBatchResponse()
}
fun updateBatchBase(batchId: UUID, request: UpdateTraceBatchBaseRequest): TraceBatchDetailResponse? = transaction {
val now = timestampLiteral(nowInstant())
val updated = TraceabilityBatchesTable.update({ TraceabilityBatchesTable.id eq batchId }) {
@@ -894,6 +1037,290 @@ object TraceabilityDao {
listFeedback().firstOrNull { it.id == feedbackId.toString() }
}
private fun preparePublicBatchSteps(
template: TraceTemplateDetailResponse,
request: CreateTraceabilityPublicBatchRequest,
now: Instant,
): PreparedPublicBatchStepsResult {
val requestByNodeId = mutableMapOf<String, TraceabilityPublicBatchStepRequest>()
request.steps.forEachIndexed { index, stepRequest ->
val node = resolveTemplateNode(template.nodes, stepRequest)
?: return PreparedPublicBatchStepsResult(
error = publicBatchBadRequest("steps[$index] 未匹配到模板节点,请传 templateNodeId 或唯一 nodeName"),
)
if (requestByNodeId.put(node.id, stepRequest) != null) {
return PreparedPublicBatchStepsResult(
error = publicBatchBadRequest("节点“${node.name}”重复传入"),
)
}
}
val steps = template.nodes.map { node ->
val stepRequest = requestByNodeId[node.id]
val values = normalizePublicBatchStepValues(node, stepRequest?.values ?: JsonObject(emptyMap()))
if (values.error.isNotBlank()) {
return PreparedPublicBatchStepsResult(error = publicBatchBadRequest(values.error))
}
val completedAt = parsePublicBatchCompletedAt(stepRequest?.completedAt ?: request.completedAt, now)
?: return PreparedPublicBatchStepsResult(
error = publicBatchBadRequest("节点“${node.name}”的 completedAt 格式无效,请使用 ISO-8601 时间"),
)
PreparedPublicBatchStep(
node = node,
values = values.value as JsonObject,
operatorName = stepRequest?.operatorName?.trim()?.takeIf { it.isNotBlank() }
?: request.operatorName.trim(),
completedAt = completedAt,
)
}
return PreparedPublicBatchStepsResult(steps = steps)
}
private fun resolveTemplateNode(
nodes: List<TraceTemplateNodeResponse>,
request: TraceabilityPublicBatchStepRequest,
): TraceTemplateNodeResponse? {
val nodeId = request.templateNodeId?.trim().orEmpty()
if (nodeId.isNotBlank()) {
return nodes.find { it.id == nodeId }
}
val nodeName = request.nodeName?.trim().orEmpty()
if (nodeName.isBlank()) {
return null
}
val matched = nodes.filter { it.name == nodeName }
return matched.singleOrNull()
}
private fun normalizePublicBatchStepValues(
node: TraceTemplateNodeResponse,
providedValues: JsonObject,
): FieldNormalizationResult {
val fieldByKey = node.fields.associateBy { it.key }
if (fieldByKey.size != node.fields.size) {
return FieldNormalizationResult(error = "节点“${node.name}”存在重复字段 Key,无法通过接口写入")
}
providedValues.keys.forEach { key ->
if (!fieldByKey.containsKey(key)) {
return FieldNormalizationResult(error = "节点“${node.name}”不包含字段“$key")
}
}
val values = linkedMapOf<String, JsonElement>()
node.fields.forEach { field ->
val hasProvidedValue = providedValues.containsKey(field.key)
val defaultValue = field.defaultValue ?: JsonNull
val normalized = if (field.fixedPreset) {
val normalizedDefault = normalizePublicFieldValue(field, defaultValue)
if (normalizedDefault.error.isNotBlank()) {
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”的固定预设值无效:${normalizedDefault.error}")
}
if (hasProvidedValue) {
val normalizedProvided = normalizePublicFieldValue(field, providedValues[field.key] ?: JsonNull)
if (normalizedProvided.error.isNotBlank()) {
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalizedProvided.error}")
}
if (normalizedProvided.value != normalizedDefault.value) {
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为固定预设值,不允许覆盖")
}
}
normalizedDefault
} else {
normalizePublicFieldValue(field, providedValues[field.key] ?: defaultValue)
}
if (normalized.error.isNotBlank()) {
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”无效:${normalized.error}")
}
if (field.required && isBlankJsonValue(normalized.value)) {
return FieldNormalizationResult(error = "节点“${node.name}”字段“${field.label}”为必填")
}
values[field.key] = normalized.value ?: JsonNull
}
return FieldNormalizationResult(value = JsonObject(values))
}
private fun normalizePublicFieldValue(
field: TraceFieldDefinitionResponse,
value: JsonElement,
): FieldNormalizationResult {
if (value == JsonNull) {
return when (field.type) {
"multi_select" -> FieldNormalizationResult(JsonArray(emptyList()))
else -> FieldNormalizationResult(JsonNull)
}
}
return when (field.type) {
"integer" -> normalizeIntegerValue(value)
"decimal" -> normalizeDecimalValue(value)
"date" -> normalizeDateValue(value)
"datetime" -> normalizeDateTimeValue(value)
"coordinate" -> normalizeCoordinateValue(value)
"select" -> normalizeSelectValue(field, value)
"multi_select" -> normalizeMultiSelectValue(field, value)
"image" -> normalizeImageValue(value)
"link", "video_url" -> normalizeUrlValue(value)
"json" -> FieldNormalizationResult(value)
else -> normalizeTextValue(value)
}
}
private fun normalizeIntegerValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是整数")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
val parsed = text.toLongOrNull() ?: return FieldNormalizationResult(error = "必须是整数")
return FieldNormalizationResult(JsonPrimitive(parsed))
}
private fun normalizeDecimalValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是小数")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
val parsed = text.toDoubleOrNull() ?: return FieldNormalizationResult(error = "必须是小数")
return FieldNormalizationResult(JsonPrimitive(parsed))
}
private fun normalizeDateValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期字符串")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
val parsed = runCatching { LocalDate.parse(text) }.getOrNull()
?: return FieldNormalizationResult(error = "日期格式应为 yyyy-MM-dd")
return FieldNormalizationResult(JsonPrimitive(parsed.toString()))
}
private fun normalizeDateTimeValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是日期时间字符串")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
val localDateTime = runCatching { LocalDateTime.parse(text.replace(' ', 'T')) }.getOrNull()
if (localDateTime != null) {
return FieldNormalizationResult(JsonPrimitive(localDateTime.format(dateTimeFormatter)))
}
val instant = runCatching { Instant.parse(text) }.getOrNull()
?: return FieldNormalizationResult(error = "日期时间格式应为 yyyy-MM-dd HH:mm:ss 或 ISO-8601")
return FieldNormalizationResult(JsonPrimitive(instant.toString()))
}
private fun normalizeCoordinateValue(value: JsonElement): FieldNormalizationResult {
val coordinate = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是坐标对象")
val lng = primitiveText(coordinate["lng"] ?: JsonNull)?.toDoubleOrNull()
val lat = primitiveText(coordinate["lat"] ?: JsonNull)?.toDoubleOrNull()
if (lng == null || lat == null) {
return FieldNormalizationResult(error = "坐标必须包含数值 lng 和 lat")
}
return FieldNormalizationResult(
buildJsonObject {
put("lng", lng)
put("lat", lat)
primitiveText(coordinate["address"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let {
put("address", it)
}
primitiveText(coordinate["source"] ?: JsonNull)?.takeIf { it.isNotBlank() }?.let {
put("source", it)
}
},
)
}
private fun normalizeSelectValue(
field: TraceFieldDefinitionResponse,
value: JsonElement,
): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
if (field.options.isNotEmpty() && text !in field.options) {
return FieldNormalizationResult(error = "必须是可选项之一:${field.options.joinToString("、")}")
}
return FieldNormalizationResult(JsonPrimitive(text))
}
private fun normalizeMultiSelectValue(
field: TraceFieldDefinitionResponse,
value: JsonElement,
): FieldNormalizationResult {
val array = value as? JsonArray ?: return FieldNormalizationResult(error = "必须是字符串数组")
val items = array.mapIndexed { index, item ->
primitiveText(item)?.takeIf { it.isNotBlank() }
?: return FieldNormalizationResult(error = "${index + 1} 项必须是非空字符串")
}
val invalid = if (field.options.isEmpty()) emptyList() else items.filter { it !in field.options }
if (invalid.isNotEmpty()) {
return FieldNormalizationResult(error = "包含非法选项:${invalid.joinToString("、")}")
}
return FieldNormalizationResult(JsonArray(items.map(::JsonPrimitive)))
}
private fun normalizeImageValue(value: JsonElement): FieldNormalizationResult {
if (value is JsonPrimitive) {
val text = value.contentOrNull?.trim().orEmpty()
return FieldNormalizationResult(if (text.isBlank()) JsonNull else JsonPrimitive(text))
}
val image = value as? JsonObject ?: return FieldNormalizationResult(error = "必须是图片地址字符串或 OSS 对象")
val objectName = primitiveText(image["objectName"] ?: JsonNull).orEmpty()
if (objectName.isBlank()) {
return FieldNormalizationResult(error = "OSS 图片对象必须包含 objectName")
}
val bucketName = primitiveText(image["bucketName"] ?: JsonNull).orEmpty().ifBlank { OSSUtils.defaultBucket() }
return FieldNormalizationResult(
buildJsonObject {
put("bucketName", bucketName)
put("objectName", objectName)
},
)
}
private fun normalizeUrlValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是链接字符串")
if (text.isBlank()) return FieldNormalizationResult(JsonNull)
if (!text.startsWith("http://") && !text.startsWith("https://")) {
return FieldNormalizationResult(error = "链接必须以 http:// 或 https:// 开头")
}
return FieldNormalizationResult(JsonPrimitive(text))
}
private fun normalizeTextValue(value: JsonElement): FieldNormalizationResult {
val text = primitiveText(value) ?: return FieldNormalizationResult(error = "必须是字符串")
return FieldNormalizationResult(JsonPrimitive(text))
}
private fun primitiveText(value: JsonElement): String? {
if (value == JsonNull) return null
return (value as? JsonPrimitive)?.contentOrNull?.trim()
}
private fun isBlankJsonValue(value: JsonElement?): Boolean = when (value) {
null, JsonNull -> true
is JsonPrimitive -> value.contentOrNull?.trim().isNullOrEmpty()
is JsonArray -> value.isEmpty()
is JsonObject -> value.isEmpty()
else -> false
}
private fun parsePublicBatchCompletedAt(raw: String?, fallback: Instant): Instant? {
val text = raw?.trim().orEmpty()
if (text.isBlank()) {
return fallback
}
return runCatching { Instant.parse(text) }.getOrNull()
}
private fun publicBatchBadRequest(message: String): TraceabilityPublicBatchOperationResult =
TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.BAD_REQUEST)
private fun publicBatchNotFound(message: String): TraceabilityPublicBatchOperationResult =
TraceabilityPublicBatchOperationResult(message = message, error = TraceabilityPublicBatchError.NOT_FOUND)
private fun TraceBatchDetailResponse.toPublicBatchResponse(overwritten: Boolean = false): TraceabilityPublicBatchResponse =
TraceabilityPublicBatchResponse(
batchId = id,
batchCode = batchCode,
publicUrl = publicUrl,
status = status,
publishedAt = publishedAt,
overwritten = overwritten,
)
private fun loadTemplateNodes(templateId: UUID): List<TraceTemplateNodeResponse> {
return TraceabilityTemplateNodesTable.selectAll()
.where { TraceabilityTemplateNodesTable.templateId eq templateId }