diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt index e08f0fe..5851209 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/HospitalLabdbUploader.kt @@ -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 { 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 = - 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 diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt new file mode 100644 index 0000000..cb4fc2e --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt @@ -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")) + } +}