|
|
|
@@ -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 }
|
|
|
|
|