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:
2026-09-11 12:03:43 +09:00
parent 575548be44
commit 6be4b5a200
2 changed files with 226 additions and 8 deletions
@@ -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}"
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 가 속한 폴더에서 찾는다 — 폴더마다 다르다.
val (good, _) = upload(csv, manifestParamsFor(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
@@ -0,0 +1,126 @@
package com.medithings.vesiscan.services.labdb
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import java.io.File
/**
* 정렬(601)이 **미업로드 재시도 대상에 들어가는지**.
*
* ## 왜 이 테스트가 있나
* 2026-09-10 병원에서 조합 CSV 39건이 망 때문에 실패했는데, 정렬은 아예 **재시도 경로가
* 없었다.** 정렬 업로드는 정렬 화면을 떠나는 순간 한 번만 쏘고, 마커를 남기지 않아 실패해도
* 흔적이 없고, "미업로드 전부 올리기"는 `align/` 을 보지 않았다. 그래서 31건이 빠진 것을
* PC 로 데이터를 꺼내 대조해서야 알았다.
*
* 여기서 지키는 성질은 셋이다.
* 1. 안 올라간 정렬은 대상에 **들어간다** — 빠지면 조용히 사라진다
* 2. 올라간 정렬은 대상에서 **빠진다** — 안 빠지면 매번 전부 다시 보낸다(분당 10건 제한)
* 3. raw 가 없는 빈 정렬 폴더는 대상이 **아니다** — 영원히 실패만 반복한다
*
* [HospitalLabdbUploader.uploadAlign] 이 마커를 보지 않는 것(사람이 memo 붙여 재전송)은
* 의도이므로, 여기서는 **대상 선정**만 본다.
*/
class HospitalAlignPendingTest {
@get:Rule val tmp = TemporaryFolder()
/** 환자·날짜 폴더 하나. [HospitalRunStore.runDir] 이 만드는 모양과 같다. */
private fun runDir(name: String): File = tmp.newFolder(name)
private fun alignDirWith(run: File, vararg cm: Int): File {
val d = File(run, "align").apply { mkdirs() }
for (c in cm) File(d, "align_${c}cm.csv").writeText("0,0,900,900,900\n", Charsets.UTF_8)
return d
}
@Test
fun `raw 가 있고 마커가 없으면 미업로드 대상`() {
val run = runDir("2026-09-10_p1")
val align = alignDirWith(run, 0, 1, 2)
assertEquals(align, HospitalLabdbUploader.pendingAlign(run))
assertFalse(HospitalLabdbUploader.isAlignUploaded(align))
}
@Test
fun `성공 마커가 있으면 대상에서 빠진다`() {
val run = runDir("2026-09-10_dd")
val align = alignDirWith(run, 0)
File(align, ".labdb_align.json").writeText("""{"inserted":20}""", Charsets.UTF_8)
assertTrue(HospitalLabdbUploader.isAlignUploaded(align))
assertNull(HospitalLabdbUploader.pendingAlign(run))
}
@Test
fun `실패 마커만 있으면 여전히 대상이다`() {
val run = runDir("2026-09-10_uuu")
val align = alignDirWith(run, 0, 1)
File(align, ".labdb_align_error.json")
.writeText("""{"message":"Unable to resolve host"}""", Charsets.UTF_8)
assertEquals(align, HospitalLabdbUploader.pendingAlign(run))
assertEquals(
"Unable to resolve host",
HospitalLabdbUploader.alignLastError(align),
)
}
/** 정렬 화면에 들어갔다 아무것도 재지 않고 나온 경우 — 실측 `2026-09-10_unnamed_123015`. */
@Test
fun `raw 가 없는 빈 정렬 폴더는 대상이 아니다`() {
val run = runDir("2026-09-10_unnamed_123015")
File(run, "align").mkdirs()
assertNull(HospitalLabdbUploader.pendingAlign(run))
}
@Test
fun `판정 요약만 있고 raw 가 없으면 대상이 아니다`() {
val run = runDir("2026-09-10_ddfq")
val d = File(run, "align").apply { mkdirs() }
File(d, "align_result.json").writeText("""{"patient":"ddfq"}""", Charsets.UTF_8)
assertNull(HospitalLabdbUploader.pendingAlign(run))
}
@Test
fun `정렬 폴더가 없으면 대상이 아니다`() {
assertNull(HospitalLabdbUploader.pendingAlign(runDir("2026-09-10_dff")))
}
/**
* 확인 측정(`align_3cm_confirm.csv`)은 raw 로 세지 않는다.
*
* 판정에 쓰지 않는 데이터라 [AlignLabdbPayload] 도 빼고 올린다. 이것만 있는 폴더를
* 대상으로 잡으면 페이로드가 비어 영원히 실패한다.
*/
@Test
fun `확인 측정만 있으면 대상이 아니다`() {
val run = runDir("2026-09-10_testdw")
val d = File(run, "align").apply { mkdirs() }
File(d, "align_3cm_confirm.csv").writeText("0,0,900\n", Charsets.UTF_8)
assertNull(HospitalLabdbUploader.pendingAlign(run))
}
/** 조합 CSV 쪽 대상 선정이 `align/` 때문에 흔들리지 않아야 한다. */
@Test
fun `조합 CSV 목록에 정렬 폴더가 섞이지 않는다`() {
val run = runDir("2026-09-10_p1-sitting")
alignDirWith(run, 0, 1)
File(run, "2026-09-10_p1_Sitting_100pct_2.3MHz-f5_c3.csv")
.writeText("ch,peak\n0,3500\n", Charsets.UTF_8)
val combos = HospitalLabdbUploader.pending(run)
assertEquals(1, combos.size)
assertTrue(combos[0].isFile)
assertTrue(combos[0].name.endsWith(".csv"))
}
}