@@ -5,6 +5,8 @@ import com.fasterxml.jackson.databind.ObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.fasterxml.jackson.module.kotlin.jacksonObjectMapper
import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.api.service.ApiCallerService
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.M18Config
import com.ffii.fpsms.m18.entity.M18BomShopSyncLog
import com.ffii.fpsms.m18.entity.M18BomShopSyncLogRepository
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.GoodsReceiptNoteResponse
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue
@@ -14,14 +16,17 @@ import com.ffii.fpsms.m18.model.M18UdfProductWrapper
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.Bom
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.entity.BomMaterial
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.master.service.ItemUomService
import com.ffii.fpsms.modules.purchaseOrder.entity.PurchaseOrderLineRepository
import org.slf4j.Logger
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.slf4j.LoggerFactory
import org.springframework.data.domain.PageRequest
import org.springframework.stereotype.Service
import org.springframework.stereotype.Service
import org.springframework.util.LinkedMultiValueMap
import org.springframework.util.LinkedMultiValueMap
import reactor.core.publisher.Mono
import reactor.core.publisher.Mono
import java.math.BigDecimal
import java.math.BigDecimal
import java.math.RoundingMode
import java.math.RoundingMode
import java.nio.charset.StandardCharsets
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.time.ZoneId
import java.time.ZoneId
/**
/**
@@ -33,6 +38,8 @@ open class M18BomForShopService(
private val m18Config: M18Config,
private val m18Config: M18Config,
private val apiCallerService: ApiCallerService,
private val apiCallerService: ApiCallerService,
private val itemUomService: ItemUomService,
private val itemUomService: ItemUomService,
private val purchaseOrderLineRepository: PurchaseOrderLineRepository,
private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository,
) {
) {
private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)
private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java)
@@ -51,6 +58,9 @@ open class M18BomForShopService(
companion object {
companion object {
private const val HARVEST_CALC_SCALE = 10
private const val HARVEST_CALC_SCALE = 10
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")
private val m18Tz: ZoneId = ZoneId.of("Asia/Hong_Kong")
private fun formatBomShopHeaderCode(itemCode: String, version: Int): String =
"BOM${itemCode}V${version.toString().padStart(3, '0')}"
}
}
@Suppress("DEPRECATION")
@Suppress("DEPRECATION")
@@ -58,55 +68,104 @@ open class M18BomForShopService(
disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
disable(JsonGenerator.Feature.ESCAPE_NON_ASCII)
}
}
/**
* Stable hash of payload **excluding** M18 header `id`, `code`, and `rev` (so version bumps do not affect equality).
* Used with [M18BomShopSyncLog] to decide V000 vs V001+.
*/
open fun contentFingerprint(request: M18BomForShopSaveRequest): String {
val json = objectMapper.writeValueAsString(normalizedForFingerprint(request))
return sha256Hex(json)
}
private fun normalizedForFingerprint(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest {
val v = request.udfbomforshop.values.firstOrNull()
?: return request
val headerNorm = v.copy(id = null, code = null, rev = null)
val linesSorted = request.udfproduct.values.sortedWith(
compareBy({ it.itemNo }, { it.udfProduct }, { it.udfIngredients }),
)
return M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(headerNorm)),
udfproduct = M18UdfProductWrapper(values = linesSorted),
)
}
private fun sha256Hex(text: String): String {
val md = MessageDigest.getInstance("SHA-256")
val bytes = md.digest(text.toByteArray(StandardCharsets.UTF_8))
return bytes.joinToString("") { "%02x".format(it) }
}
/**
/**
* Builds M18 save body from a persisted BOM (materials loaded).
* Builds M18 save body from a persisted BOM (materials loaded).
* [headerM18IdOverride] optional M18 header record id (e.g. from `/bom/by-item-code` `bomM18Id`) when DB column is stale.
* Otherwise uses [Bom.m18Id] when set for **update**; omitted for **create**.
* Returns null if required M18 ids are missing (caller should log and skip).
* [headerM18IdOverride] optional M18 header record id when forcing update; skips version/fingerprint logic for **id** only,
* reuses latest logged [M18BomShopSyncLog.m18HeaderCode] when possible .
* Otherwise uses [Bom.m18Id] when the normalized payload matches the latest log; on content change, bumps `BOM{item}Vnnn` .
*/
*/
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
open fun buildSaveRequest(bom: Bom, headerM18IdOverride: Long? = null): M18BomForShopSaveRequest? {
val code = bom.code ?: return null
val flowTypeId = resolveFlowTypeId(code)
val bomId = bom.id ?: return null
val routingCode = bom.code ?: return null
val itemCode = bom.item?.code?.trim().orEmpty().ifEmpty {
logger.warn("[M18 BOM] bom.item.code missing; cannot build M18 BOM shop payload. bomId=$bomId")
return null
}
val flowTypeId = resolveFlowTypeId(routingCode)
val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null
val udfUnit = bom.uom?.m18Id?.takeIf { it > 0 } ?: return null
val outputQty = bom.outputQty ?: BigDecimal.ZERO
val outputQty = bom.outputQty ?: BigDecimal.ZERO
val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty)
val (udfHarvest, udfHarvestUnit) = resolveUdfHarvestFields(bom, outputQty)
val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli()
val udfEffectiveDate = bom.created?.atZone(m18Tz)?.toInstant()?.toEpochMilli()
val effectiveHeaderM18Id =
headerM18IdOverride?.takeIf { it > 0 } ?: bom.m18Id?.takeIf { it > 0 }
val lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }
if (lines.isEmpty()) {
logger.warn("[M18 BOM] BOM id=$bomId code=$routingCode has no materials; skipping M18 save")
return null
}
val (headerCode, rev, headerM18IdForRequest) = resolveHeaderCodeAndM18Id(
bomId = bomId,
itemCode = itemCode,
lines = lines,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
bomYield = bom.yield,
bomName = bom.name,
bomDescription = bom.description,
flowTypeId = flowTypeId,
headerM18IdOverride = headerM18IdOverride,
bomM18Id = bom.m18Id?.takeIf { it > 0 },
)
val header = M18MainUdfBomForShopValue(
val header = M18MainUdfBomForShopValue(
id = effectiveHeaderM18Id?.toString(),
code = code,
id = headerM18IdForRequest ?.toString(),
code = headerC ode,
beId = bomShopMainBeId,
beId = bomShopMainBeId,
desc = bom.name ?: bom.description,
desc = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
descEn = bom.name ?: bom.description,
udfBomCode = deriveUdfBomCode(code),
rev = deriveRev(code),
udfBomCode = itemCode ,
rev = rev,
udfUnit = udfUnit,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bom.yield,
udfYieldratePP = bom.yield,
udftypeoffood = "半成品",
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
staffId = 232,
flowTypeId = flowTypeId,
flowTypeId = flowTypeId,
virDeptId = 117,
virDeptId = 117,
status = "Y",
status = "Y",
)
)
val lines = bom.bomMaterials
.filter { it.deleted != true }
.sortedBy { it.id ?: 0L }
.mapIndexedNotNull { idx, mat -> toProductLine(mat, idx + 1) }
if (lines.isEmpty()) {
logger.warn("[M18 BOM] BOM id=${bom.id} code=$code has no materials; skipping M18 save")
return null
}
logger.info(
logger.info(
"[M18 BOM] buildSaveRequest fpsmsBomId=${bom.id} code=$code mainM18Id=$effectiveHeaderM18Id " +
"(m18HeaderIdO verride=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
"[M18 BOM] buildSaveRequest fpsmsBomId=$bomId routingCode=$routingCode itemCode=$itemCode headerCode=$headerCode " +
"mainM18Id=$headerM18IdForRequest (override=$headerM18IdOverride, bom.m18Id=${bom.m18Id})",
)
)
return M18BomForShopSaveRequest(
return M18BomForShopSaveRequest(
@@ -115,6 +174,110 @@ open class M18BomForShopService(
)
)
}
}
@Suppress("LongParameterList")
private fun resolveHeaderCodeAndM18Id(
bomId: Long,
itemCode: String,
lines: List<M18UdfProductSaveValue>,
udfUnit: Long,
udfHarvest: String,
udfHarvestUnit: String?,
udfEffectiveDate: Long?,
bomYield: BigDecimal?,
bomName: String?,
bomDescription: String?,
flowTypeId: Int,
headerM18IdOverride: Long?,
bomM18Id: Long?,
): Triple<String, String?, Long?> {
val draftHeader = M18MainUdfBomForShopValue(
id = null,
code = null,
beId = bomShopMainBeId,
desc = bomName ?: bomDescription,
descEn = bomName ?: bomDescription,
udfBomCode = itemCode,
rev = null,
udfUnit = udfUnit,
udfHarvest = udfHarvest,
udfHarvestUnit = udfHarvestUnit,
udfEffectiveDate = udfEffectiveDate,
udfYieldratePP = bomYield,
udftypeoffood = "半成品",
udfconfirmed = true,
staffId = 232,
flowTypeId = flowTypeId,
virDeptId = 117,
status = "Y",
)
val draftRequest = M18BomForShopSaveRequest(
udfbomforshop = M18MainUdfBomForShopWrapper(values = listOf(draftHeader)),
udfproduct = M18UdfProductWrapper(values = lines),
)
val fp = contentFingerprint(draftRequest)
val forcedId = headerM18IdOverride?.takeIf { it > 0 }
if (forcedId != null) {
val latest = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val codeForUpdate =
latest?.m18HeaderCode?.takeIf { it.isNotBlank() }
?: formatBomShopHeaderCode(itemCode, 0)
val forcedRev = parseTrailingVersion(codeForUpdate) ?: "000"
return Triple(codeForUpdate, forcedRev, forcedId)
}
val latestLog = m18BomShopSyncLogRepository.findFirstByBomIdOrderByIdDesc(bomId)
val prevFp = resolveLogFingerprint(latestLog)
val prevCodeTrimmed = latestLog?.m18HeaderCode?.trim().orEmpty()
val samePayload = latestLog != null && prevFp != null && prevFp == fp && prevCodeTrimmed.isNotEmpty()
if (samePayload) {
val revReuse = parseTrailingVersion(prevCodeTrimmed) ?: "000"
return Triple(prevCodeTrimmed, revReuse, bomM18Id)
}
val maxV = maxVersionFromLogs(bomId, itemCode)
val nextV = maxV + 1
val newCode = formatBomShopHeaderCode(itemCode, nextV)
val rev = nextV.toString().padStart(3, '0')
return Triple(newCode, rev, null)
}
private fun resolveLogFingerprint(log: M18BomShopSyncLog?): String? {
if (log == null) return null
log.requestFingerprint?.takeIf { it.isNotBlank() }?.let { return it }
val json = log.requestJson ?: return null
return runCatching {
val prev = objectMapper.readValue(json, M18BomForShopSaveRequest::class.java)
contentFingerprint(prev)
}.getOrNull()
}
private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int {
val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$")
// Only successful syncs advance the numeric tail; failed attempts log a code but must not consume Vnnn.
return m18BomShopSyncLogRepository.findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId)
.mapNotNull { row ->
val c = row.m18HeaderCode?.trim().orEmpty().ifEmpty {
extractHeaderCodeFromJson(row.requestJson).orEmpty()
}
versionPat.find(c)?.groupValues?.get(1)?.toIntOrNull()
}
.maxOrNull() ?: -1
}
private fun extractHeaderCodeFromJson(json: String?): String? {
if (json.isNullOrBlank()) return null
return runCatching {
val node = objectMapper.readTree(json)
val text = node.path("udfbomforshop").path("values").path(0).path("code").asText()
text.trim().takeIf { it.isNotEmpty() }
}.getOrNull()
}
private fun parseTrailingVersion(headerCode: String): String? =
Regex("V(\\d+)$").find(headerCode.trim())?.groupValues?.get(1)?.padStart(3, '0')
/**
/**
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
* From the **finished-good** [Bom.item] stock unit [com.ffii.fpsms.modules.master.entity.UomConversion.code]
* (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
* (pattern `LETTER_PREFIX` + `DIGITS` + `UNIT_SUFFIX`, e.g. PACK2LB): harvest qty = outputQty × digits, unit = suffix.
@@ -160,6 +323,23 @@ open class M18BomForShopService(
logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
logger.warn("[M18 BOM] material UOM code missing bomMaterialId=${mat.id}")
return null
return null
}
}
val itemId = mat.item?.id
val latestPoLine = itemId?.let { id ->
purchaseOrderLineRepository.findLatestLinesForBomM18ByItemId(id, PageRequest.of(0, 1)).firstOrNull()
}
val itemCode = mat.item?.code?.trim()?.takeIf { it.isNotEmpty() }
val supplierM18Id = itemCode?.let { code ->
purchaseOrderLineRepository.findLatestPoSupplierM18IdByItemCodeNative(code)
.firstOrNull()
?.takeIf { it > 0L }
}
/**
* M18 line price unit id ([M18PurchaseOrderPot.unitId]): prefer [PurchaseOrderLine.uomM18] from M18 PO sync,
* else [PurchaseOrderLine.uom] when uomM18 is missing.
*/
val purchaseUnitM18Id =
latestPoLine?.uomM18?.m18Id?.takeIf { it > 0L }
?: latestPoLine?.uom?.m18Id?.takeIf { it > 0L }
val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
val udfqty = (mat.qty ?: BigDecimal.ZERO).setScale(8, RoundingMode.HALF_UP).toDouble()
return M18UdfProductSaveValue(
return M18UdfProductSaveValue(
id = mat.m18Id?.takeIf { it > 0 },
id = mat.m18Id?.takeIf { it > 0 },
@@ -167,7 +347,8 @@ open class M18BomForShopService(
udfProduct = proId,
udfProduct = proId,
udfIngredients = mat.itemName ?: mat.item?.name,
udfIngredients = mat.itemName ?: mat.item?.name,
udfBaseUnit = udfBaseUnit,
udfBaseUnit = udfBaseUnit,
udfSupplier = 0L,
udfSupplier = supplierM18Id,
udfpurchaseUnit = purchaseUnitM18Id,
itemNo = String.format("%6d", lineNo),
itemNo = String.format("%6d", lineNo),
udfoptions = "",
udfoptions = "",
udfoption = 0.0,
udfoption = 0.0,
@@ -175,16 +356,6 @@ open class M18BomForShopService(
)
)
}
}
private fun deriveUdfBomCode(fullCode: String): String {
val v = Regex("^(.*)V(\\d+)$").find(fullCode)
return if (v != null) v.groupValues[1] else fullCode
}
private fun deriveRev(fullCode: String): String? {
val v = Regex("^.*V(\\d+)$").find(fullCode) ?: return null
return v.groupValues[1]
}
private fun resolveFlowTypeId(code: String): Int = when {
private fun resolveFlowTypeId(code: String): Int = when {
code.startsWith("TOA") -> 1
code.startsWith("TOA") -> 1
code.startsWith("BOMPP") || code.startsWith("PP") -> 3
code.startsWith("BOMPP") || code.startsWith("PP") -> 3