Преглед изворни кода

try to update the m18 bom with a new version number

production
[email protected] пре 1 недеља
родитељ
комит
506d39c7f3
5 измењених фајлова са 289 додато и 34 уклоњено
  1. +10
    -0
      src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt
  2. +14
    -0
      src/main/java/com/ffii/fpsms/m18/model/M18BomForShopSaveAttemptResult.kt
  3. +201
    -18
      src/main/java/com/ffii/fpsms/m18/service/M18BomForShopService.kt
  4. +41
    -0
      src/main/java/com/ffii/fpsms/m18/service/M18BomHeaderLookupService.kt
  5. +23
    -16
      src/main/java/com/ffii/fpsms/modules/master/service/BomService.kt

+ 10
- 0
src/main/java/com/ffii/fpsms/m18/entity/M18BomShopSyncLogRepository.kt Прегледај датотеку

@@ -9,4 +9,14 @@ interface M18BomShopSyncLogRepository : AbstractRepository<M18BomShopSyncLog, Lo

/** Successful M18 udfBomForShop saves only — used for `BOM{item}Vnnn` version allocation. */
fun findTop100ByBomIdAndSyncedIsTrueOrderByIdDesc(bomId: Long): List<M18BomShopSyncLog>

fun findFirstByBomIdAndSyncedIsTrueAndRequestFingerprintOrderByIdDesc(
bomId: Long,
requestFingerprint: String,
): M18BomShopSyncLog?

fun findFirstByBomIdAndSyncedIsTrueAndM18HeaderCodeOrderByIdDesc(
bomId: Long,
m18HeaderCode: String,
): M18BomShopSyncLog?
}

+ 14
- 0
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,
)

+ 201
- 18
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<String>()
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<String, Long>? {
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)



+ 41
- 0
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<M18BomListResponse, M18CommonListRequest>(
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 }
}
}

+ 23
- 16
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


Loading…
Откажи
Сачувај