From 506d39c7f370647dc47ca2a4c81f318886889cc2 Mon Sep 17 00:00:00 2001 From: "vluk@2fi-solutions.com.hk" Date: Sat, 13 Jun 2026 23:30:00 +0800 Subject: [PATCH] try to update the m18 bom with a new version number --- .../m18/entity/M18BomShopSyncLogRepository.kt | 10 + .../model/M18BomForShopSaveAttemptResult.kt | 14 ++ .../fpsms/m18/service/M18BomForShopService.kt | 219 ++++++++++++++++-- .../m18/service/M18BomHeaderLookupService.kt | 41 ++++ .../modules/master/service/BomService.kt | 39 ++-- 5 files changed, 289 insertions(+), 34 deletions(-) create mode 100644 src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt create mode 100644 src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt diff --git a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt index ee1b83a..664e3f3 100644 --- a/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt +++ b/src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt @@ -9,4 +9,14 @@ interface M18BomShopSyncLogRepository : AbstractRepository + + fun findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( + bomId: Long, + requestFingerprint: String, + ): M18BomShopSyncLog? + + fun findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( + bomId: Long, + m18HeaderCode: String, + ): M18BomShopSyncLog? } diff --git a/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt new file mode 100644 index 0000000..f7b503b --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt @@ -0,0 +1,14 @@ +package com.ffii.fpsms.m18.model + +/** + * Outcome of [com.ffii.fpsms.m18.service.M18BomForShopService.saveBomForShopWithVersionRetry] + * (may differ from the initial request when header version was bumped). + */ +data class M18BomForShopSaveAttemptResult( + val request: M18BomForShopSaveRequest, + val response: GoodsReceiptNoteResponse?, + val callError: Throwable?, + val versionBumps: Int = 0, + /** True when M18 save was skipped because an identical payload was already synced successfully. */ + val skippedUnchanged: Boolean = false, +) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt index 6c09218..5227ea2 100644 --- a/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt @@ -8,6 +8,7 @@ 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.M18BomForShopSaveAttemptResult import com.ffii.fpsms.m18.model.M18BomForShopSaveRequest import com.ffii.fpsms.m18.model.M18MainUdfBomForShopValue import com.ffii.fpsms.m18.model.M18MainUdfBomForShopWrapper @@ -44,6 +45,7 @@ open class M18BomForShopService( private val m18BomShopSyncLogRepository: M18BomShopSyncLogRepository, private val shopService: ShopService, private val m18VendorLookupService: M18VendorLookupService, + private val m18BomHeaderLookupService: M18BomHeaderLookupService, ) { private val logger: Logger = LoggerFactory.getLogger(M18BomForShopService::class.java) @@ -239,17 +241,18 @@ open class M18BomForShopService( 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) - ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) - return Triple(prevCodeTrimmed, revReuse, bomM18Id) + // Identical BOM details already synced — reuse header code + M18 id (update), never allocate a new version. + findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> + val reuseCode = match.m18HeaderCode?.trim().orEmpty() + if (reuseCode.isNotEmpty()) { + val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: bomM18Id + val revReuse = parseTrailingVersion(reuseCode) + ?: "0".repeat(BOM_SHOP_HEADER_VERSION_DIGITS) + return Triple(reuseCode, revReuse, reuseId) + } } + // Content changed — next version number (new M18 header code). val maxV = maxVersionFromLogs(bomId, itemCode) val nextV = maxV + 1 val newCode = formatBomShopHeaderCode(itemCode, nextV) @@ -257,15 +260,11 @@ open class M18BomForShopService( 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 findSuccessfulSyncByFingerprint(bomId: Long, fingerprint: String): M18BomShopSyncLog? = + m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc( + bomId, + fingerprint, + ) private fun maxVersionFromLogs(bomId: Long, itemCode: String): Int { val versionPat = Regex("^BOM${Regex.escape(itemCode)}V(\\d+)$") @@ -438,6 +437,190 @@ open class M18BomForShopService( else -> 1 } + /** M18 rejects duplicate header [M18MainUdfBomForShopValue.code] on create (core_101903). */ + open fun isSameCodeFoundError(resp: GoodsReceiptNoteResponse?): Boolean { + if (resp == null || resp.status) return false + return resp.messages.any { msg -> + msg.msgCode == "core_101903" || + msg.msgDetail?.contains("Same Code found", ignoreCase = true) == true + } + } + + private fun withHeaderM18Id( + request: M18BomForShopSaveRequest, + headerCode: String, + m18Id: Long, + ): M18BomForShopSaveRequest { + val header = request.udfbomforshop.values.firstOrNull() + ?: return request + val rev = parseTrailingVersion(headerCode) ?: header.rev + val newHeader = header.copy( + id = m18Id.toString(), + code = headerCode, + rev = rev, + ) + return request.copy( + udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), + ) + } + + /** + * Increments `BOM{item}Vnnnn` tail, clears header `id` (new M18 row), updates `rev`. + * Returns null when [udfBomCode] is missing. + */ + open fun bumpHeaderVersionForRetry(request: M18BomForShopSaveRequest): M18BomForShopSaveRequest? { + val header = request.udfbomforshop.values.firstOrNull() ?: return null + val itemCode = header.udfBomCode?.trim().orEmpty().ifEmpty { return null } + val currentCode = header.code?.trim().orEmpty() + val currentV = + parseTrailingVersion(currentCode)?.toIntOrNull() + ?: Regex("V(\\d+)$").find(currentCode)?.groupValues?.get(1)?.toIntOrNull() + ?: -1 + val nextV = currentV + 1 + val newCode = formatBomShopHeaderCode(itemCode, nextV) + val newRev = nextV.toString().padStart(BOM_SHOP_HEADER_VERSION_DIGITS, '0') + val newHeader = header.copy(id = null, code = newCode, rev = newRev) + return request.copy( + udfbomforshop = request.udfbomforshop.copy(values = listOf(newHeader)), + ) + } + + /** + * Saves to M18. On duplicate code: update existing row when details match a prior sync or M18 lookup; + * bump version only when BOM content (fingerprint) is new. + */ + open fun saveBomForShopWithVersionRetry( + request: M18BomForShopSaveRequest, + bomId: Long, + maxSameCodeRetries: Int = 20, + ): M18BomForShopSaveAttemptResult { + val fp = contentFingerprint(request) + findSuccessfulSyncByFingerprint(bomId, fp)?.let { match -> + val reuseCode = match.m18HeaderCode?.trim().orEmpty() + val reuseId = match.m18RecordId?.takeIf { it > 0L } + if (reuseCode.isNotEmpty() && reuseId != null) { + logger.info( + "[M18 BOM] Unchanged BOM details; skip new code (reuse headerCode={} m18Id={})", + reuseCode, + reuseId, + ) + return M18BomForShopSaveAttemptResult( + request = withHeaderM18Id(request, reuseCode, reuseId), + response = GoodsReceiptNoteResponse(recordId = reuseId, status = true), + callError = null, + versionBumps = 0, + skippedUnchanged = true, + ) + } + } + + var current = request + var bumps = 0 + var lastResp: GoodsReceiptNoteResponse? = null + var lastError: Throwable? = null + val attachIdAttemptedForCode = mutableSetOf() + while (true) { + lastError = null + try { + lastResp = saveBomForShop(current) + } catch (e: Exception) { + lastError = e + break + } + if ( + lastResp == null || + !isSameCodeFoundError(lastResp) || + bumps >= maxSameCodeRetries + ) { + break + } + + val header = current.udfbomforshop.values.firstOrNull() ?: break + val code = header.code?.trim().orEmpty() + + // Same details already synced — update existing row, do not create a new version code. + val reuseFromSync = resolveReuseFromSuccessfulSync(bomId, fp) + if (reuseFromSync != null) { + val (reuseCode, reuseId) = reuseFromSync + current = withHeaderM18Id(current, reuseCode, reuseId) + logger.info( + "[M18 BOM] Same Code found; same details as prior sync — update headerCode={} m18Id={}", + reuseCode, + reuseId, + ) + continue + } + + // Code exists in M18 without header id — attach id and update (same code, not a new version). + if (header.id.isNullOrBlank() && code.isNotEmpty() && code !in attachIdAttemptedForCode) { + attachIdAttemptedForCode.add(code) + val m18Id = m18BomHeaderLookupService.findM18IdByHeaderCode(code) + if (m18Id != null) { + current = withHeaderM18Id(current, code, m18Id) + logger.info("[M18 BOM] Same Code found; attach M18 id={} for headerCode={}", m18Id, code) + continue + } + } + + // Same code already holds this exact content in our sync log — update that row. + if (code.isNotEmpty()) { + val logAtCode = + m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( + bomId, + code, + ) + if (logAtCode?.requestFingerprint == fp && logAtCode.m18RecordId != null) { + current = withHeaderM18Id(current, code, logAtCode.m18RecordId!!) + logger.info( + "[M18 BOM] Same Code found; headerCode={} already has matching content — update m18Id={}", + code, + logAtCode.m18RecordId, + ) + continue + } + } + + // Content differs from existing code — allocate next version (new code for new details). + val bumped = bumpHeaderVersionForRetry(current) ?: break + val bumpedCode = bumped.udfbomforshop.values.firstOrNull()?.code?.trim().orEmpty() + if (bumpedCode.isNotEmpty()) { + val logAtBumped = + m18BomShopSyncLogRepository.findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc( + bomId, + bumpedCode, + ) + if (logAtBumped?.requestFingerprint == fp && logAtBumped.m18RecordId != null) { + current = withHeaderM18Id(bumped, bumpedCode, logAtBumped.m18RecordId!!) + logger.info( + "[M18 BOM] Version {} already synced with same details — update m18Id={}", + bumpedCode, + logAtBumped.m18RecordId, + ) + continue + } + } + current = bumped + bumps++ + logger.info( + "[M18 BOM] Same Code found; content changed — bump version (#$bumps) headerCode={}", + bumpedCode, + ) + } + return M18BomForShopSaveAttemptResult( + request = current, + response = lastResp, + callError = lastError, + versionBumps = bumps, + ) + } + + private fun resolveReuseFromSuccessfulSync(bomId: Long, fingerprint: String): Pair? { + val match = findSuccessfulSyncByFingerprint(bomId, fingerprint) ?: return null + val reuseCode = match.m18HeaderCode?.trim().orEmpty().ifEmpty { return null } + val reuseId = match.m18RecordId?.takeIf { it > 0L } ?: return null + return reuseCode to reuseId + } + open fun toJson(request: M18BomForShopSaveRequest): String = objectMapper.writeValueAsString(request) diff --git a/src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt b/src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt new file mode 100644 index 0000000..9edf6be --- /dev/null +++ b/src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt @@ -0,0 +1,41 @@ +package com.ffii.fpsms.m18.service + +import com.ffii.fpsms.api.service.ApiCallerService +import com.ffii.fpsms.m18.model.M18BomListResponse +import com.ffii.fpsms.m18.model.M18CommonListRequest +import com.ffii.fpsms.m18.model.StSearchType +import org.slf4j.Logger +import org.slf4j.LoggerFactory +import org.springframework.stereotype.Service + +/** + * M18 udfBomForShop header lookup by [code] — isolated from [M18MasterDataService] / [M18BomForShopService] cycles. + */ +@Service +open class M18BomHeaderLookupService( + private val apiCallerService: ApiCallerService, +) { + private val logger: Logger = LoggerFactory.getLogger(M18BomHeaderLookupService::class.java) + + private val fetchListApi = "/search/search" + + open fun findM18IdByHeaderCode(headerCode: String): Long? { + val trimmed = headerCode.trim() + if (trimmed.isEmpty()) return null + val conds = "(code=equal=$trimmed)" + val listResponse = try { + apiCallerService.get( + fetchListApi, + M18CommonListRequest( + stSearch = StSearchType.BOM.value, + params = null, + conds = conds, + ), + ).block() + } catch (e: Exception) { + logger.warn("(findM18IdByHeaderCode) M18 search failed code=$trimmed: ${e.message}") + null + } + return listResponse?.values?.firstOrNull()?.id?.takeIf { it > 0L } + } +} diff --git a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt index fba477d..41ef63f 100644 --- a/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt +++ b/src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt @@ -458,14 +458,12 @@ open class BomService( val req = m18BomForShopService.buildSaveRequest(bom, m18HeaderId) ?: return M18BomShopSyncTriggerResult(bomId, false, skippedReason = "Cannot build M18 payload (missing m18 UOM/item ids or no materials)") - val requestJsonPayload = m18BomForShopService.toJson(req) - var resp: GoodsReceiptNoteResponse? = null - var callError: Throwable? = null - try { - resp = m18BomForShopService.saveBomForShop(req) - } catch (e: Exception) { - callError = e - } + val saveAttempt = m18BomForShopService.saveBomForShopWithVersionRetry(req, bomId) + val reqFinal = saveAttempt.request + val requestJsonPayload = m18BomForShopService.toJson(reqFinal) + val resp = saveAttempt.response + val callError = saveAttempt.callError + val skippedUnchanged = saveAttempt.skippedUnchanged val responseJsonPayload = when { resp != null -> m18BomForShopService.toJson(resp) @@ -483,7 +481,11 @@ open class BomService( val msgSummary = resp?.messages?.joinToString("; ") { it.msgDetail ?: it.msgCode ?: "" }.orEmpty() val apiStatus = resp?.status == true - val recordId = resp?.recordId ?: 0L + val recordId = when { + resp?.recordId != null && resp.recordId > 0L -> resp.recordId + skippedUnchanged -> reqFinal.udfbomforshop.values.firstOrNull()?.id?.toLongOrNull() ?: 0L + else -> 0L + } val result = when { callError != null -> @@ -496,15 +498,18 @@ open class BomService( ) resp == null -> M18BomShopSyncTriggerResult(bomId, false, skippedReason = "M18 API returned null") - resp.status == true && resp.recordId > 0L -> { - bom.m18Id = resp.recordId + skippedUnchanged || (resp.status == true && recordId > 0L) -> { + bom.m18Id = recordId bomRepository.saveAndFlush(bom) M18BomShopSyncTriggerResult( bomId = bomId, synced = true, - recordId = resp.recordId, + recordId = recordId, status = true, - messageSummary = msgSummary.ifBlank { null }, + messageSummary = when { + skippedUnchanged -> "unchanged BOM details (skipped duplicate M18 save)" + else -> msgSummary.ifBlank { null } + }, ) } else -> @@ -520,6 +525,8 @@ open class BomService( val logMessage = listOfNotNull( msgSummary.ifBlank { null }, + if (skippedUnchanged) "skippedUnchanged=true" else null, + if (saveAttempt.versionBumps > 0) "versionBumps=${saveAttempt.versionBumps}" else null, callError?.message, result.skippedReason?.takeIf { !result.synced }, ).joinToString("; ").take(4000) @@ -527,9 +534,9 @@ open class BomService( m18BomShopSyncLogRepository.save( M18BomShopSyncLog().apply { this.bomId = bomId - finishedItemCode = req.udfbomforshop.values.firstOrNull()?.udfBomCode - m18HeaderCode = req.udfbomforshop.values.firstOrNull()?.code - requestFingerprint = m18BomForShopService.contentFingerprint(req) + finishedItemCode = reqFinal.udfbomforshop.values.firstOrNull()?.udfBomCode + m18HeaderCode = reqFinal.udfbomforshop.values.firstOrNull()?.code + requestFingerprint = m18BomForShopService.contentFingerprint(reqFinal) m18RecordId = recordId.takeIf { it > 0 } m18ApiStatus = apiStatus synced = result.synced