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