|
|
@@ -0,0 +1,114 @@ |
|
|
|
|
|
package com.ffii.fpsms.modules.stock.service |
|
|
|
|
|
|
|
|
|
|
|
import com.ffii.core.exception.BadRequestException |
|
|
|
|
|
import com.ffii.fpsms.modules.common.SecurityUtils |
|
|
|
|
|
import com.ffii.fpsms.modules.stock.web.model.SaveStockInLineRequest |
|
|
|
|
|
import java.math.BigDecimal |
|
|
|
|
|
import java.math.RoundingMode |
|
|
|
|
|
import java.time.LocalDateTime |
|
|
|
|
|
import java.time.format.DateTimeFormatter |
|
|
|
|
|
|
|
|
|
|
|
object StockQtyRoundSupport { |
|
|
|
|
|
private val TAG_REGEX = Regex("""\[QTY_ROUND[^\]]*\]""") |
|
|
|
|
|
private val AT_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm") |
|
|
|
|
|
private val REMARKS_MAX = 500 |
|
|
|
|
|
|
|
|
|
|
|
enum class Mode { |
|
|
|
|
|
HALF_UP, |
|
|
|
|
|
CEILING, |
|
|
|
|
|
FLOOR, |
|
|
|
|
|
CUSTOM, |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
fun isNotInteger(qty: BigDecimal): Boolean { |
|
|
|
|
|
return qty.stripTrailingZeros().scale() > 0 |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ |
|
|
|
|
|
fun applyIfRequested( |
|
|
|
|
|
currentQty: BigDecimal, |
|
|
|
|
|
currentRemarks: String?, |
|
|
|
|
|
request: SaveStockInLineRequest, |
|
|
|
|
|
defaultSource: String, |
|
|
|
|
|
): Pair<BigDecimal, String?> { |
|
|
|
|
|
val mode = parseMode(request.stockQtyRoundMode) ?: return currentQty to currentRemarks |
|
|
|
|
|
if (!isNotInteger(currentQty)) { |
|
|
|
|
|
return currentQty to currentRemarks |
|
|
|
|
|
} |
|
|
|
|
|
val after = roundQty(currentQty, mode, request.stockQtyCustomQty) |
|
|
|
|
|
val source = request.stockQtyRoundSource?.trim()?.ifBlank { null } ?: defaultSource |
|
|
|
|
|
val tag = buildTag( |
|
|
|
|
|
mode = mode, |
|
|
|
|
|
before = currentQty, |
|
|
|
|
|
after = after, |
|
|
|
|
|
source = source, |
|
|
|
|
|
) |
|
|
|
|
|
val withReason = if (mode == Mode.CUSTOM) { |
|
|
|
|
|
val reason = request.stockQtyCustomReason?.trim().orEmpty() |
|
|
|
|
|
if (reason.isEmpty()) tag else "$tag 原因:$reason" |
|
|
|
|
|
} else { |
|
|
|
|
|
tag |
|
|
|
|
|
} |
|
|
|
|
|
return after to mergeRemark(currentRemarks, withReason) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ |
|
|
|
|
|
fun parseMode(raw: String?): Mode? { |
|
|
|
|
|
val value = raw?.trim()?.uppercase() ?: return null |
|
|
|
|
|
if (value.isEmpty()) return null |
|
|
|
|
|
return try { |
|
|
|
|
|
Mode.valueOf(value) |
|
|
|
|
|
} catch (_: IllegalArgumentException) { |
|
|
|
|
|
throw BadRequestException("Invalid stockQtyRoundMode: $raw") |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ |
|
|
|
|
|
fun roundQty(before: BigDecimal, mode: Mode, customQty: BigDecimal?): BigDecimal { |
|
|
|
|
|
return when (mode) { |
|
|
|
|
|
Mode.HALF_UP -> before.setScale(0, RoundingMode.HALF_UP) |
|
|
|
|
|
Mode.CEILING -> before.setScale(0, RoundingMode.CEILING) |
|
|
|
|
|
Mode.FLOOR -> before.setScale(0, RoundingMode.FLOOR) |
|
|
|
|
|
Mode.CUSTOM -> { |
|
|
|
|
|
val custom = customQty ?: throw BadRequestException("stockQtyCustomQty is required for CUSTOM") |
|
|
|
|
|
if (custom <= BigDecimal.ZERO) { |
|
|
|
|
|
throw BadRequestException("stockQtyCustomQty must be greater than 0") |
|
|
|
|
|
} |
|
|
|
|
|
if (isNotInteger(custom)) { |
|
|
|
|
|
throw BadRequestException("stockQtyCustomQty must be an integer") |
|
|
|
|
|
} |
|
|
|
|
|
custom.setScale(0, RoundingMode.DOWN) |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ |
|
|
|
|
|
fun buildTag( |
|
|
|
|
|
mode: Mode, |
|
|
|
|
|
before: BigDecimal, |
|
|
|
|
|
after: BigDecimal, |
|
|
|
|
|
source: String, |
|
|
|
|
|
at: LocalDateTime = LocalDateTime.now(), |
|
|
|
|
|
by: String = currentUsername(), |
|
|
|
|
|
): String { |
|
|
|
|
|
return "[QTY_ROUND mode=${mode.name} before=${plain(before)} after=${plain(after)} source=$source by=${sanitize(by)} at=${at.format(AT_FORMAT)}]" |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** FP-MTMS Version Checklist | Functions Ref. No. 56 | v1.0.6 | 2026-10-14 */ |
|
|
|
|
|
fun mergeRemark(existing: String?, tag: String): String { |
|
|
|
|
|
val stripped = existing?.replace(TAG_REGEX, "")?.trim().orEmpty() |
|
|
|
|
|
val merged = if (stripped.isEmpty()) tag else "$tag $stripped" |
|
|
|
|
|
return if (merged.length <= REMARKS_MAX) merged else merged.take(REMARKS_MAX) |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun currentUsername(): String { |
|
|
|
|
|
val user = SecurityUtils.getUser().orElse(null) |
|
|
|
|
|
return user?.username ?: user?.name ?: "unknown" |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
private fun plain(qty: BigDecimal): String = qty.stripTrailingZeros().toPlainString() |
|
|
|
|
|
|
|
|
|
|
|
private fun sanitize(value: String): String { |
|
|
|
|
|
return value.replace(Regex("""[\s\[\]]+"""), "_").ifBlank { "unknown" } |
|
|
|
|
|
} |
|
|
|
|
|
} |