diff --git a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt index d871e5f..edb977e 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/HospitalRunStore.kt @@ -346,6 +346,12 @@ object HospitalRunStore { * 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 **`align_{자세}/`** 에 둔다 * (2026-10-01 — 자세마다 정렬을 다시 하므로 자세별로 가른다. 같은 `align/` 에 쓰면 * Supine 정렬 원시 데이터가 Sitting 정렬로 덮인다). 2026-10-01 이전 폴더는 `align/`. + * + * 폴더는 **환자·날짜·자세**로만 갈리고 시작 시각은 키에 없다. 같은 날 같은 자세를 다시 + * 정렬하면 새 세션이 첫 파일을 쓰기 전에 앞 세션의 파일을 `prev_HHmm/`(앞 세션 시작 시각) + * 로 옮긴다([archivePreviousAlignRun]) — 그래서 이 폴더 바로 밑은 언제나 **마지막 세션 하나**다. + * 2026-10-15 까지는 안 옮겼다: 새 0cm 이 옛 0cm 을 덮고 옛 1~4cm 은 새 세션 것처럼 남아 + * 그대로 labdb 에 올라갔다(0cm 만 잰 팬텀 세션에 100 record). */ fun alignDir(patient: String, startedAt: Date, posture: com.medithings.vesiscan.models.ClinicalPosture): File { val dir = File(runDir(patient, startedAt), alignDirName(posture)) @@ -366,6 +372,80 @@ object HospitalRunStore { fun alignPostureSuffix(alignDir: File): String = alignDir.name.removePrefix("align").takeIf { it.startsWith("_") }.orEmpty() + /** 앞 세션 보관 폴더 — `prev_1432`(앞 세션 시작 HHmm), 같은 시각이 겹치면 `prev_1432_2`. */ + val PREV_DIR_RE = Regex("""^prev_\d{4}(_\d+)?$""") + + /** 보관 폴더(`prev_HHmm`)면 그 정렬 폴더, 아니면 그대로. labdb 세션 이름·자세 꼬리는 정렬 폴더 기준이다. */ + fun alignSessionDir(dir: File): File = + if (PREV_DIR_RE.matches(dir.name)) dir.parentFile ?: dir else dir + + /** + * 한 정렬 **세션**의 파일 — 탐색·확인·좌우·좌우 흐름·재부착과 그 IMU 형제. + * standing 확인(`_standing_confirm`)은 **아니다**: 정렬 앞에 그날·자세 단위로 일어나고 번호를 + * 폴더의 파일 수로 매겨 덮어쓰지 않는다([StandingConfirmCard]) — 세션을 치울 때 남겨 둔다. + */ + private val SESSION_FILE_RE = Regex( + """^align_-?\d+cm(_confirm|_lateral_stream|_lateral|_reattach)?(_r\d+)?(_imu)?\.csv$""", + RegexOption.IGNORE_CASE, + ) + + /** 요약 `started_at` 의 형식. [archivePreviousAlignRun] 이 "같은 세션인가"를 이 문자열로 비교한다. */ + fun isoStamp(d: Date): String = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSSXXX", Locale.US).format(d) + + /** + * 정렬 폴더에 **다른 세션**의 파일이 남아 있으면 `prev_HHmm/` 으로 옮긴다. 새 세션이 첫 파일을 + * 쓰기 직전에 부른다([AnchorAlignView]). + * + * - 세션 파일([SESSION_FILE_RE])·`align_result.json`·업로드 마커(`.labdb_align*`)를 옮긴다. + * 마커가 같이 가므로 이미 올라간 세션은 다시 안 올라가고, 못 올라간 세션은 보관 폴더에서 + * 재시도 대상이 된다([HospitalLabdbUploader.pendingAligns]). + * - standing 확인 파일은 남긴다. 요약에 `standing_confirm` 이 있었으면 그 키만 남긴 요약을 + * 다시 써서, 뒤이어 쓰는 정렬 요약이 그것을 이어받게 한다([writeAlignSummary]). + * - 요약의 `started_at` 이 [startedAt] 과 같으면 이 세션이 이미 쓴 것이다 — 손대지 않는다. + * - 폴더 이름은 앞 세션 시작 시각 HHmm. 요약이 없거나 시각이 없으면(옛 앱, 판정 전에 죽은 + * 세션) 가장 오래된 파일의 수정 시각. + * + * @return 만든 보관 폴더. 옮길 것이 없으면 null. + */ + fun archivePreviousAlignRun(dir: File, startedAt: Date): File? { + val sessionFiles = dir.listFiles()?.filter { it.isFile && SESSION_FILE_RE.matches(it.name) }.orEmpty() + if (sessionFiles.isEmpty()) return null + val summaryFile = File(dir, "align_result.json") + val summary = summaryFile.takeIf { it.isFile } + ?.let { runCatching { JSONObject(it.readText(Charsets.UTF_8)) }.getOrNull() } + val prevStarted = summary?.optString("started_at").orEmpty() + if (prevStarted.isNotEmpty() && prevStarted == isoStamp(startedAt)) return null + + val hhmm = Regex("""T(\d{2}):(\d{2})""").find(prevStarted) + ?.let { it.groupValues[1] + it.groupValues[2] } + ?: SimpleDateFormat("HHmm", Locale.US).format(Date(sessionFiles.minOf { it.lastModified() })) + var target = File(dir, "prev_$hhmm") + var n = 2 + while (target.exists()) { target = File(dir, "prev_${hhmm}_$n"); n++ } + if (!target.mkdirs()) { + android.util.Log.w("HospitalRunStore", "앞 정렬 세션 보관 폴더 생성 실패: $target") + return null + } + val markers = dir.listFiles().orEmpty().filter { it.isFile && it.name.startsWith(".labdb_align") } + val toMove = sessionFiles + markers + listOfNotNull(summaryFile.takeIf { it.isFile }) + for (f in toMove) { + val dst = File(target, f.name) + if (!f.renameTo(dst)) { + runCatching { f.copyTo(dst, overwrite = true); f.delete() } + .onFailure { android.util.Log.w("HospitalRunStore", "앞 정렬 세션 보관 실패 ${f.name}: ${it.message}") } + } + } + summary?.opt("standing_confirm")?.let { sc -> + runCatching { + summaryFile.writeText(JSONObject().apply { + put("standing_confirm", sc) + for (key in listOf("patient", "save_name", "posture")) summary.opt(key)?.let { put(key, it) } + }.toString(2), Charsets.UTF_8) + } + } + return target + } + /** * 한 위치의 cycle 전부를 CSV 로 저장한다. * diff --git a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt index 6c44206..4158382 100644 --- a/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt +++ b/app/src/main/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayload.kt @@ -36,7 +36,9 @@ import java.util.Locale * 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다. * * 단, **이 세션의 파일만**이다. 폴더가 환자·날짜·자세로만 갈려서 같은 날 다시 정렬하면 앞 - * 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15). + * 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15). 같은 날부터 + * 앱은 새 세션이 첫 파일을 쓰기 전에 앞 세션을 `prev_HHmm/` 으로 옮기므로 + * ([HospitalRunStore.archivePreviousAlignRun]) 이 필터는 그 전 앱이 만든 폴더를 위한 이중 안전장치다. * * ``` * record = 한 위치의 한 cycle @@ -340,14 +342,16 @@ object AlignLabdbPayload { * 모양이 아니면(단위시험 임시 폴더) 종전대로 `save_name` 을 쓴다. */ internal fun sessionBase(alignDir: File, saveName: String, summary: JSONObject?): String { - val dirName = alignDir.parentFile?.name.orEmpty() + // 보관 폴더(`align_supine/prev_1432`, 2026-10-15)에서 올려도 환자·날짜와 자세는 정렬 폴더의 것이다. + val sessionDir = com.medithings.vesiscan.services.HospitalRunStore.alignSessionDir(alignDir) + val dirName = sessionDir.parentFile?.name.orEmpty() val root = if (RUN_DIR_RE.matches(dirName)) dirName else saveName.ifBlank { "align" } val hhmm = summary?.optString("started_at").orEmpty() .let { Regex("""T(\d{2}):(\d{2})""").find(it) } ?.let { it.groupValues[1] + it.groupValues[2] } // 자세별 정렬 폴더(`align_sitting`)면 그 꼬리를 붙인다 — 같은 날 같은 환자의 자세별 // 세션이 갈려야 한다(2026-10-01). 옛 `align/` 은 꼬리 없음. - val posture = com.medithings.vesiscan.services.HospitalRunStore.alignPostureSuffix(alignDir) + val posture = com.medithings.vesiscan.services.HospitalRunStore.alignPostureSuffix(sessionDir) return root + "_align" + posture + (hhmm?.let { "_$it" } ?: "") } 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 f0c7c98..c0993cc 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 @@ -105,11 +105,19 @@ object HospitalLabdbUploader { /** * 그 환자·날짜 폴더의 미업로드 정렬 폴더 **전부** — 옛 `align/` 과 자세별 `align_{자세}/` * (2026-10-01, 자세마다 정렬). 이름순이라 `align`, `align_sitting`, `align_standing`, `align_supine`. + * 각 정렬 폴더 뒤에 그 밑의 보관된 앞 세션(`prev_HHmm/`, 2026-10-15)이 따른다 — 업로드 마커가 + * 세션과 같이 옮겨지므로 못 올라간 세션만 남는다. */ fun pendingAligns(runDir: File): List = runDir.listFiles() ?.filter { it.isDirectory && com.medithings.vesiscan.services.HospitalRunStore.ALIGN_DIR_RE.matches(it.name) } ?.sortedBy { it.name } + ?.flatMap { dir -> + listOf(dir) + (dir.listFiles() + ?.filter { it.isDirectory && com.medithings.vesiscan.services.HospitalRunStore.PREV_DIR_RE.matches(it.name) } + ?.sortedBy { it.name } + ?: emptyList()) + } ?.filter { dir -> !isAlignUploaded(dir) && dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true @@ -208,7 +216,7 @@ object HospitalLabdbUploader { fun uploadAlignAsync(alignDir: File, memo: String = "") { if (!LabdbCredentials.isRegistered) return scope.launch { - uploadingName.value = "정렬 (${alignDir.parentFile?.name ?: alignDir.name})" + uploadingName.value = "정렬 (${com.medithings.vesiscan.services.HospitalRunStore.alignSessionDir(alignDir).parentFile?.name ?: alignDir.name})" val (ok, msg) = uploadAlign(alignDir, memo) uploadingName.value = null lastMessage.value = if (ok) "정렬 업로드 — $msg" else "정렬 업로드 실패 — $msg" diff --git a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt index f9b76e0..3826c44 100644 --- a/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt +++ b/app/src/main/java/com/medithings/vesiscan/ui/views/clinical/AnchorAlignView.kt @@ -175,6 +175,12 @@ fun AnchorAlignView(appState: AppState) { * labdb 분당 제한(10건)을 정렬 하나가 먹는다. */ var alignDirty by remember { mutableStateOf(false) } + /** + * 이 세션이 폴더에 앞 세션 파일이 남았는지 봤는가. 첫 파일을 쓰기 직전 한 번만 본다 — 폴더가 + * 환자·날짜·자세로만 갈려 앞 세션의 파일이 그대로 남기 때문이다 + * ([HospitalRunStore.archivePreviousAlignRun], 2026-10-15). [처음부터 다시 정렬] 이 되돌린다. + */ + var archiveChecked by remember { mutableStateOf(false) } // ── 방광 용적 ──────────────────────────────────────────────────────────── // 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가 @@ -430,6 +436,15 @@ fun AnchorAlignView(appState: AppState) { // 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서 // 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다. val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) + // 같은 날 같은 환자·자세 폴더에 앞 세션 파일이 남아 있으면 먼저 `prev_HHmm/` 으로 치운다. + // 안 치우면 새 0cm 이 옛 0cm 을 덮고 옛 1~4cm 은 이 세션 것처럼 남아 그대로 올라간다 + // (2026-10-15 팬텀 세션 100 record). 세션마다 한 번이면 된다 — 그 뒤 파일은 전부 이 세션 것. + if (!archiveChecked) { + archiveChecked = true + HospitalRunStore.archivePreviousAlignRun(dir, runStartedAt)?.let { + android.util.Log.i("AnchorAlign", "앞 정렬 세션 보관 → ${it.name}") + } + } hasAlignData = true alignDirty = true // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. @@ -1109,8 +1124,9 @@ fun AnchorAlignView(appState: AppState) { Spacer(Modifier.height(8.dp)) TextButton( onClick = { - // 앞 시도는 지금 올려 둔다 — 새 시도가 같은 폴더의 파일을 위치마다 - // 덮어쓰므로, 여기서 안 올리면 그 데이터는 어디에도 남지 않는다. + // 앞 시도는 지금 올려 둔다. 새 시도가 첫 파일을 쓸 때 앞 시도의 파일은 + // `prev_HHmm/` 으로 옮겨져 남지만(2026-10-15), 서버에는 지금 보내야 재시도 + // 큐가 아니라 바로 올라간다. if (hasAlignData && alignDirty) { HospitalLabdbUploader.uploadAlignAsync( HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture)) @@ -1120,7 +1136,7 @@ fun AnchorAlignView(appState: AppState) { guide.reset(); cm = 0; last = null; records = emptyList(); error = null lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ lrRefChannels = null; confirmTiltDeg = null - attempts.clear(); detachedCms.clear() + attempts.clear(); detachedCms.clear(); archiveChecked = false lrConfirmed = false; lrError = null; lrRunning = false disconnectedMidRun = false decidedAtCm = null; sweepStopped = false; confirmDone = false diff --git a/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignArchiveTest.kt b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignArchiveTest.kt new file mode 100644 index 0000000..a6507dd --- /dev/null +++ b/app/src/test/java/com/medithings/vesiscan/services/HospitalRunStoreAlignArchiveTest.kt @@ -0,0 +1,148 @@ +/* + * HospitalRunStoreAlignArchiveTest — 같은 날 같은 환자·자세 폴더에 남은 **앞 정렬 세션**을 치우는 규칙. + * + * 2026-10-15: 0cm 만 잰 팬텀 세션에 1~4cm 80 record 가 붙어 올라갔다. 정렬 폴더가 환자·날짜·자세로만 + * 갈려 앞 세션의 파일이 남아 있었고, 새 0cm 은 옛 0cm 을 덮어썼다. 새 세션이 첫 파일을 쓰기 전에 + * 앞 세션을 `prev_HHmm/` 으로 옮기면 폴더 바로 밑은 언제나 마지막 세션 하나이고 앞 세션 원본도 남는다. + */ +package com.medithings.vesiscan.services + +import com.medithings.vesiscan.services.labdb.AlignLabdbPayload +import org.json.JSONObject +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 +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale + +class HospitalRunStoreAlignArchiveTest { + + @get:Rule val tmp = TemporaryFolder() + + private fun summary( + startedAt: String, + standingConfirm: Boolean = false, + attemptCms: List = listOf(0, 1), + ): String = JSONObject().apply { + put("patient", "P7"); put("save_name", "P7"); put("posture", "Supine") + put("started_at", startedAt) + put("attempts", JSONObject().apply { attemptCms.forEach { put(it.toString(), 1) } }) + if (standingConfirm) put("standing_confirm", JSONObject().put("ok", true)) + }.toString() + + private fun touch(dir: File, vararg names: String) = + names.forEach { File(dir, it).writeText("0,0,900\n", Charsets.UTF_8) } + + @Test fun `앞 세션 파일은 prev_HHmm 으로 옮기고 폴더 바로 밑에는 남지 않는다`() { + val d = tmp.newFolder("2026-10-15_P7", "align_supine") + touch(d, "align_0cm.csv", "align_0cm_imu.csv", "align_1cm.csv", "align_1cm_r2.csv", + "align_1cm_confirm_r3.csv", "align_1cm_lateral.csv", "align_1cm_lateral_stream.csv", + "align_1cm_lateral_stream_imu.csv", "align_1cm_reattach.csv", ".labdb_align.json") + File(d, "align_result.json").writeText(summary("2026-10-15T14:32:10.000+09:00"), Charsets.UTF_8) + + val now = Date() + val prev = HospitalRunStore.archivePreviousAlignRun(d, now)!! + assertEquals("prev_1432", prev.name) + assertEquals(d, prev.parentFile) + // 옮긴 뒤 폴더 바로 밑에는 보관 폴더뿐이다. + assertEquals(listOf("prev_1432"), d.listFiles()!!.map { it.name }) + // 보관 폴더에는 세션 파일·요약·업로드 마커가 전부 있다 — 마커가 같이 가야 다시 안 올라간다. + assertEquals( + listOf(".labdb_align.json", "align_0cm.csv", "align_0cm_imu.csv", "align_1cm.csv", + "align_1cm_confirm_r3.csv", "align_1cm_lateral.csv", "align_1cm_lateral_stream.csv", + "align_1cm_lateral_stream_imu.csv", "align_1cm_r2.csv", "align_1cm_reattach.csv", + "align_result.json"), + prev.listFiles()!!.map { it.name }.sorted(), + ) + // 다시 불러도 할 일이 없다 — 이 세션이 쓴 파일은 아직 없다. + assertNull(HospitalRunStore.archivePreviousAlignRun(d, now)) + } + + @Test fun `같은 세션이 이미 쓴 파일은 건드리지 않는다`() { + val d = tmp.newFolder("2026-10-15_P7", "align_supine") + val started = Date() + touch(d, "align_0cm.csv") + File(d, "align_result.json").writeText(summary(HospitalRunStore.isoStamp(started)), Charsets.UTF_8) + assertNull(HospitalRunStore.archivePreviousAlignRun(d, started)) + assertTrue(File(d, "align_0cm.csv").isFile) + } + + @Test fun `standing 확인 파일과 판정은 남고 정렬 세션만 옮겨진다`() { + // standing 확인은 정렬 앞에 그날·자세 단위로 일어나고 번호를 폴더 기준으로 매긴다 — 세션 것이 아니다. + val d = tmp.newFolder("2026-10-15_P7", "align_standing") + touch(d, "align_2cm_standing_confirm.csv", "align_2cm_standing_confirm_imu.csv", + "align_2cm_standing_confirm_r2.csv", "align_0cm.csv", "align_1cm.csv") + File(d, "align_result.json").writeText( + summary("2026-10-15T10:05:00.000+09:00", standingConfirm = true), Charsets.UTF_8) + + val prev = HospitalRunStore.archivePreviousAlignRun(d, Date())!! + assertEquals( + listOf("align_2cm_standing_confirm.csv", "align_2cm_standing_confirm_imu.csv", + "align_2cm_standing_confirm_r2.csv", "align_result.json", "prev_1005"), + d.listFiles()!!.map { it.name }.sorted(), + ) + // 남긴 요약에는 standing 확인 판정만 있고 앞 세션의 attempts·started_at 은 없다. + val left = JSONObject(File(d, "align_result.json").readText(Charsets.UTF_8)) + assertTrue(left.has("standing_confirm")) + assertFalse(left.has("attempts")) + assertFalse(left.has("started_at")) + assertEquals("P7", left.getString("patient")) + // 보관 폴더의 요약은 원본 그대로다. + assertTrue(JSONObject(File(prev, "align_result.json").readText(Charsets.UTF_8)).has("attempts")) + } + + @Test fun `standing 확인만 있는 폴더는 세션이 아니라 아무것도 옮기지 않는다`() { + val d = tmp.newFolder("2026-10-15_P7", "align_standing") + touch(d, "align_2cm_standing_confirm.csv") + File(d, "align_result.json").writeText( + summary("2026-10-15T10:00:00.000+09:00", standingConfirm = true), Charsets.UTF_8) + assertNull(HospitalRunStore.archivePreviousAlignRun(d, Date())) + assertTrue(File(d, "align_result.json").isFile) + } + + @Test fun `요약이 없으면(옛 앱) 가장 오래된 파일의 시각으로 보관한다`() { + val d = tmp.newFolder("2026-10-15_P7", "align") + touch(d, "align_0cm.csv", "align_1cm.csv") + val t = SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.US).parse("2026-10-15 09:41")!!.time + File(d, "align_0cm.csv").setLastModified(t) + File(d, "align_1cm.csv").setLastModified(t + 60_000) + val prev = HospitalRunStore.archivePreviousAlignRun(d, Date())!! + assertEquals("prev_0941", prev.name) + assertEquals(2, prev.listFiles()!!.size) + } + + @Test fun `같은 시각의 보관 폴더가 이미 있으면 _2 를 붙인다`() { + val d = tmp.newFolder("2026-10-15_P7", "align_supine") + File(d, "prev_1432").mkdirs() + touch(d, "align_0cm.csv") + File(d, "align_result.json").writeText(summary("2026-10-15T14:32:10.000+09:00"), Charsets.UTF_8) + assertEquals("prev_1432_2", HospitalRunStore.archivePreviousAlignRun(d, Date())!!.name) + } + + @Test fun `보관 뒤 새 세션의 업로드에는 새 세션 파일만 들어가고 앞 세션은 보관 폴더에서 통째로 올라간다`() { + // 2026-10-15 의 실제 순서: 앞 세션 0~4cm → 새 세션 0cm 만. 종전에는 100 record 가 새 세션으로 올라갔다. + val d = tmp.newFolder("2026-10-15_phantom", "align_supine") + for (cm in 0..4) File(d, "align_${cm}cm.csv") + .writeText((0 until 20).joinToString("") { ci -> "$ci,0,900,900,3500\n" }, Charsets.UTF_8) + File(d, "align_result.json").writeText( + summary("2026-10-15T14:20:00.000+09:00", attemptCms = (0..4).toList()), Charsets.UTF_8) + + val now = Date() + assertEquals("prev_1420", HospitalRunStore.archivePreviousAlignRun(d, now)!!.name) + assertTrue(HospitalRunStore.writeAlignCycles(d, 0, List(20) { List(6) { DoubleArray(4) { 900.0 } } })) + File(d, "align_result.json").writeText(JSONObject().apply { + put("started_at", HospitalRunStore.isoStamp(now)); put("attempts", JSONObject().put("0", 1)) + }.toString(), Charsets.UTF_8) + + val p = AlignLabdbPayload.build(d)!! + assertEquals(20, p.getInt("recordCount")) + assertEquals(0, p.getJSONObject("params").getInt("stale_files_skipped")) + assertEquals(100, AlignLabdbPayload.build(File(d, "prev_1420"))!!.getInt("recordCount")) + } +} diff --git a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt index 28c1e75..20fdc82 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/AlignLabdbPayloadTest.kt @@ -476,4 +476,13 @@ class AlignLabdbPayloadTest { File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8) assertNull(AlignLabdbPayload.build(d)) } + + @Test fun `보관 폴더의 세션 이름은 원래 정렬 폴더 기준이다`() { + // 2026-10-15: 앞 세션은 `align_supine/prev_1432/` 로 옮겨진다. 거기서 올려도 환자·날짜와 자세는 + // 정렬 폴더의 것이고, 시각은 그 세션의 started_at 이다. + val run = tmp.newFolder("2026-10-15_P7") + val prev = File(run, "align_supine/prev_1432").apply { mkdirs() } + val s = JSONObject().apply { put("started_at", "2026-10-15T14:32:10.000+09:00") } + assertEquals("2026-10-15_P7_align_supine_1432", AlignLabdbPayload.sessionBase(prev, "P7", s)) + } } 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 index 494e395..cc3f59a 100644 --- a/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt +++ b/app/src/test/java/com/medithings/vesiscan/services/labdb/HospitalAlignPendingTest.kt @@ -166,4 +166,20 @@ class HospitalAlignPendingTest { assertTrue(combos[0].isFile) assertTrue(combos[0].name.endsWith(".csv")) } + + /** 보관된 앞 세션(`prev_HHmm/`, 2026-10-15)도 대상이다 — 마커가 같이 옮겨지므로 못 올라간 것만 남는다. */ + @Test + fun `보관된 앞 세션 폴더도 미업로드 대상에 들어간다`() { + val run = runDir("2026-10-15_p9") + val supine = File(run, "align_supine").apply { mkdirs() } + File(supine, "align_0cm.csv").writeText("0,0,900\n", Charsets.UTF_8) // 지금 세션 — 미업로드 + val sent = File(supine, "prev_1420").apply { mkdirs() } + File(sent, "align_0cm.csv").writeText("0,0,900\n", Charsets.UTF_8) + File(sent, ".labdb_align.json").writeText("""{"inserted":100}""", Charsets.UTF_8) // 올라간 앞 세션 + val lost = File(supine, "prev_1105").apply { mkdirs() } + File(lost, "align_0cm.csv").writeText("0,0,900\n", Charsets.UTF_8) // 못 올라간 앞 세션 + File(supine, "prev_junk").mkdirs() // 보관 폴더 아님 + + assertEquals(listOf(supine, lost), HospitalLabdbUploader.pendingAligns(run)) + } } diff --git a/docs/LABDB_DATATYPES.md b/docs/LABDB_DATATYPES.md index 39fd1e0..fe16d04 100644 --- a/docs/LABDB_DATATYPES.md +++ b/docs/LABDB_DATATYPES.md @@ -280,7 +280,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다( | 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 | | `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 | | 앱 반영 | 상수 2개 변경 + 최상위 `subject` 추가 | -| 앞 세션 파일 혼입 | 2026-10-15 · 같은 날 같은 환자를 다시 정렬하면 폴더에 남은 앞 세션의 `align_{n}cm.csv` 가 새 세션에 같이 올라갔다(0cm 만 잰 팬텀 세션에 100 record). 이후 앱은 요약 `attempts` 기준으로 거른다 — **그 전 앱으로 올린 601 세션은 `positions_measured` 와 `params.positions` 길이가 다르면 혼입 의심**, 서버에서 `align_cm ∉ positions[].align_cm` 인 record 를 빼고 봐야 합니다 | +| 앞 세션 파일 혼입 | 2026-10-15 · 같은 날 같은 환자를 다시 정렬하면 폴더에 남은 앞 세션의 `align_{n}cm.csv` 가 새 세션에 같이 올라갔다(0cm 만 잰 팬텀 세션에 100 record). 이후 앱은 새 세션이 첫 파일을 쓰기 전에 앞 세션을 `align_{자세}/prev_HHmm/` 으로 옮기고, 업로드도 요약 `attempts` 기준으로 거른다 — **그 전 앱으로 올린 601 세션은 `positions_measured` 와 `params.positions` 길이가 다르면 혼입 의심**, 서버에서 `align_cm ∉ positions[].align_cm` 인 record 를 빼고 봐야 합니다 | > 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의 > 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.