fix(labdb): 정렬(601)에 재시도 경로가 없어 조용히 사라지고 있었다
정렬 업로드는 정렬 화면을 떠나는 순간 한 번만 쏘고, 마커를 남기지 않고, "미업로드 전부 올리기"는 `align/` 을 보지 않았다. 세 가지가 겹쳐 **실패하면 흔적도 없이 없어졌다.** 2026-09-10 병원에서 조합 CSV 39건이 망 때문에 실패한 건 화면에 남아 나중에 올릴 수 있었지만, 같은 시각의 정렬은 올라갔는지조차 알 수 없었다. PC 로 데이터를 꺼내 labdb 와 대조해서야 31건이 빠진 것을 찾았다. ## 바꾼 것 pendingAlign(runDir) 안 올라간 정렬 폴더를 찾는다 pendingAll() 조합 CSV + 정렬 폴더 (디렉터리면 정렬 한 건) uploadAllPending() 둘을 한 버튼으로 올린다 uploadAlign() 성공/실패 마커를 남긴다 마커는 **자동 재시도가 무엇을 건너뛸지 고를 때만** 본다. `uploadAlign` 자체는 마커를 보지 않으므로 "memo 붙여 다시 보내기"는 그대로 된다 — 원래 의도를 깨지 않으면서 빠진 것을 알 수 있게 하는 것이 목적이다. raw CSV 가 없는 빈 정렬 폴더는 대상에서 뺀다. 정렬 화면에 들어갔다 아무것도 재지 않고 나오면 빈 폴더만 남는데(실측 `2026-09-10_unnamed_123015`), 그걸 올리려 들면 영원히 실패한다. 확인 측정(`align_Ncm_confirm.csv`)도 raw 로 세지 않는다 — 페이로드에서 빠지는 데이터라 그것만 있는 폴더는 올릴 것이 없다. 화면은 이미 pendingAll()/uploadAllPending() 만 쓰므로 따로 손대지 않았다. ## 배포 전 주의 폰에 마커가 없으면 이 변경은 **이미 올라간 것까지 전부 다시 올리려 한다.** PC 가 올린 세션은 다른 deviceId 소유라 서버가 409 SESSION_ID_CONFLICT 로 거절하므로 영구 실패로 남는다. 그래서 labdb 에 있는 것(조합 111 · 정렬 34)에 대해 폰 쪽 마커를 먼저 심었다. 마커의 `verified` 필드에 근거를 적었다 — `labdb_listing` 은 목록에서 직접 확인, `upload_conflict` 는 409 로 존재만 확인(레코드 수는 대조 못 함). 테스트 8개 추가 (전체 151, 실패 0). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -38,6 +38,25 @@ object HospitalLabdbUploader {
|
||||
private const val OK_SUFFIX = ".labdb.json"
|
||||
private const val ERR_SUFFIX = ".labdb_error.json"
|
||||
|
||||
/**
|
||||
* 정렬 마커 — 조합 CSV 와 달리 **폴더 하나가 세션 하나**라 마커도 폴더 안에 둔다.
|
||||
*
|
||||
* ## 왜 뒤늦게 넣었나
|
||||
* 정렬 업로드는 "같은 정렬을 memo 붙여 다시 보낼 수 있어야 한다"는 이유로 일부러 마커를
|
||||
* 남기지 않았다. 그런데 그 대가로 **성공도 실패도 흔적이 없어**, 2026-09-10 병원에서
|
||||
* 망이 막혔을 때 무엇이 빠졌는지 앱에서 알 길이 없었다(폰에 마커 0개). 실제로 PC 로
|
||||
* 꺼내 대조해서야 31건이 빠진 것을 찾았다.
|
||||
*
|
||||
* 마커는 **자동 재시도가 무엇을 건너뛸지 판단할 때만** 본다([pendingAlign]).
|
||||
* [uploadAlign] 자체는 마커를 보지 않으므로 사람이 다시 보내는 것은 그대로 된다 —
|
||||
* 원래 의도를 깨지 않으면서 빠진 것을 알 수 있게 하는 것이 목적이다.
|
||||
*/
|
||||
private const val ALIGN_OK = ".labdb_align.json"
|
||||
private const val ALIGN_ERR = ".labdb_align_error.json"
|
||||
|
||||
/** 정렬 raw 파일 이름. [AlignLabdbPayload] 가 읽는 것과 같은 규칙이어야 한다. */
|
||||
private val ALIGN_CSV_RE = Regex("""^align_(-?\d+)cm\.csv$""", RegexOption.IGNORE_CASE)
|
||||
|
||||
/**
|
||||
* 연속 업로드 사이 간격.
|
||||
*
|
||||
@@ -66,6 +85,28 @@ object HospitalLabdbUploader {
|
||||
private fun markerOk(csv: File) = File(csv.parentFile, csv.name + OK_SUFFIX)
|
||||
private fun markerErr(csv: File) = File(csv.parentFile, csv.name + ERR_SUFFIX)
|
||||
|
||||
private fun alignMarkerOk(alignDir: File) = File(alignDir, ALIGN_OK)
|
||||
private fun alignMarkerErr(alignDir: File) = File(alignDir, ALIGN_ERR)
|
||||
|
||||
/** 그 정렬 폴더가 한 번이라도 올라갔는가. 사람이 다시 보내는 것은 막지 않는다. */
|
||||
fun isAlignUploaded(alignDir: File): Boolean = alignMarkerOk(alignDir).exists()
|
||||
|
||||
fun alignLastError(alignDir: File): String? = alignMarkerErr(alignDir).takeIf { it.exists() }
|
||||
?.let { runCatching { JSONObject(it.readText()).optString("message") }.getOrNull() }
|
||||
|
||||
/**
|
||||
* 그 환자·날짜 폴더에 아직 안 올라간 정렬이 있으면 그 `align/` 폴더.
|
||||
*
|
||||
* raw CSV 가 하나도 없으면 대상이 아니다 — 정렬 화면에 들어갔다 아무것도 재지 않고 나오면
|
||||
* 빈 폴더만 남는데(실측: `2026-09-10_unnamed_123015`), 그걸 올리려 들면 영원히 실패한다.
|
||||
*/
|
||||
fun pendingAlign(runDir: File): File? {
|
||||
val dir = File(runDir, "align")
|
||||
if (!dir.isDirectory || isAlignUploaded(dir)) return null
|
||||
val hasRaw = dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true
|
||||
return dir.takeIf { hasRaw }
|
||||
}
|
||||
|
||||
/**
|
||||
* 폴더에서 아직 안 올라간 조합 CSV 를 찾는다.
|
||||
*
|
||||
@@ -157,16 +198,48 @@ object HospitalLabdbUploader {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 정렬 한 폴더를 올린다. 성공/실패 모두 마커를 남긴다.
|
||||
*
|
||||
* **[isAlignUploaded] 를 보지 않는다.** 이미 올라간 정렬을 memo 를 붙여 다시 보내는 흐름이
|
||||
* 실제로 있고(처음에 못 적은 증상을 나중에 적는다), labdb 는 testId 가 같으면 갱신하므로
|
||||
* 중복 세션이 쌓이지 않는다. 마커는 [pendingAlign] 이 **자동 재시도 대상을 고를 때만** 쓴다.
|
||||
*/
|
||||
suspend fun uploadAlign(alignDir: File, memo: String = ""): Pair<Boolean, String> {
|
||||
if (!LabdbCredentials.isRegistered) return false to "labdb 미등록 — 설정에서 등록하세요."
|
||||
val payload = AlignLabdbPayload.build(alignDir, memo)
|
||||
?: return false to "정렬 원시 데이터가 없습니다."
|
||||
return try {
|
||||
val r = LabdbClient.uploadJson(payload)
|
||||
alignMarkerErr(alignDir).delete()
|
||||
runCatching {
|
||||
alignMarkerOk(alignDir).writeText(
|
||||
JSONObject().apply {
|
||||
put("sessionId", r.sessionId)
|
||||
put("created", r.created)
|
||||
put("totalRecords", r.totalRecords)
|
||||
put("inserted", r.inserted)
|
||||
put("duplicates", r.duplicates)
|
||||
put("errors", r.errors)
|
||||
put("uploadedAt", System.currentTimeMillis())
|
||||
}.toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
}
|
||||
true to "업로드 ${r.inserted}/${r.totalRecords} (중복 ${r.duplicates})"
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "align upload failed", e)
|
||||
false to (e.message ?: e.javaClass.simpleName)
|
||||
val msg = e.message ?: e.javaClass.simpleName
|
||||
runCatching {
|
||||
alignMarkerErr(alignDir).writeText(
|
||||
JSONObject().apply {
|
||||
put("message", msg)
|
||||
put("failedAt", System.currentTimeMillis())
|
||||
}.toString(2),
|
||||
Charsets.UTF_8,
|
||||
)
|
||||
}
|
||||
false to msg
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,8 +255,17 @@ object HospitalLabdbUploader {
|
||||
*
|
||||
* 화면이 "미업로드 N건"을 띄우는 근거. 종전에는 이번 실행의 폴더만 봤다.
|
||||
*/
|
||||
/**
|
||||
* 올릴 것 전부 — **조합 CSV(600) + 정렬 폴더(601)**.
|
||||
*
|
||||
* 반환 원소가 파일이면 조합 CSV, **디렉터리면 정렬 한 건**이다. 두 종류를 한 목록에 담는
|
||||
* 이유는 화면이 "미업로드 N건"을 한 숫자로 보여주고 버튼 하나로 올리기 때문이다. 나눠
|
||||
* 두면 "조합은 다 올렸는데 정렬이 남았다"는 상태를 사용자가 볼 방법이 없어진다 — 정확히
|
||||
* 2026-09-10 에 일어난 일이다.
|
||||
*/
|
||||
fun pendingAll(): List<File> =
|
||||
com.medithings.vesiscan.services.HospitalRunStore.allRunDirs().flatMap { pending(it) }
|
||||
com.medithings.vesiscan.services.HospitalRunStore.allRunDirs()
|
||||
.flatMap { pending(it) + listOfNotNull(pendingAlign(it)) }
|
||||
|
||||
/**
|
||||
* 저장된 **전 폴더**의 미업로드 CSV 를 전부 올린다.
|
||||
@@ -206,7 +288,7 @@ object HospitalLabdbUploader {
|
||||
}
|
||||
running = true
|
||||
try {
|
||||
val targets = dirs.flatMap { pending(it) }
|
||||
val targets = dirs.flatMap { pending(it) + listOfNotNull(pendingAlign(it)) }
|
||||
pendingCount.intValue = targets.size
|
||||
if (targets.isEmpty()) {
|
||||
lastMessage.value = "올릴 것이 없습니다."
|
||||
@@ -214,16 +296,26 @@ object HospitalLabdbUploader {
|
||||
}
|
||||
var ok = 0
|
||||
var fail = 0
|
||||
targets.forEachIndexed { i, csv ->
|
||||
uploadingName.value = "${csv.parentFile?.name}/${csv.name}"
|
||||
// 매니페스트는 그 CSV 가 속한 폴더에서 찾는다 — 폴더마다 다르다.
|
||||
val (good, _) = upload(csv, manifestParamsFor(csv))
|
||||
var alignOk = 0
|
||||
targets.forEachIndexed { i, target ->
|
||||
// 디렉터리면 정렬 한 건, 파일이면 조합 CSV 하나 — [pendingAll] 참조.
|
||||
val good = if (target.isDirectory) {
|
||||
uploadingName.value = "정렬 ${target.parentFile?.name}"
|
||||
val (g, _) = uploadAlign(target)
|
||||
if (g) alignOk++
|
||||
g
|
||||
} else {
|
||||
uploadingName.value = "${target.parentFile?.name}/${target.name}"
|
||||
// 매니페스트는 그 CSV 가 속한 폴더에서 찾는다 — 폴더마다 다르다.
|
||||
upload(target, manifestParamsFor(target)).first
|
||||
}
|
||||
if (good) ok++ else fail++
|
||||
pendingCount.intValue = (targets.size - (ok + fail)).coerceAtLeast(0)
|
||||
if (i < targets.lastIndex) delay(GAP_MS)
|
||||
}
|
||||
uploadingName.value = null
|
||||
lastMessage.value = "전체 업로드 — 성공 $ok · 실패 $fail"
|
||||
lastMessage.value = "전체 업로드 — 성공 $ok · 실패 $fail" +
|
||||
if (alignOk > 0) " (정렬 $alignOk)" else ""
|
||||
return lastMessage.value!!
|
||||
} finally {
|
||||
running = false
|
||||
|
||||
Reference in New Issue
Block a user