fix(align): 새 정렬 세션이 첫 파일을 쓰기 전에 같은 폴더의 앞 세션을 prev_HHmm/ 으로 옮긴다

정렬 폴더(`align_{자세}/`)는 환자·날짜·자세로만 갈리고 시작 시각이 키에 없다. 같은 날 같은
자세를 다시 정렬하면 새 세션은 잰 위치의 파일만 덮어쓰고 나머지는 앞 세션 것이 그대로
남았다 — 2026-10-15 팬텀 세션에서 0cm 만 쟀는데 앞 세션의 1~4cm 80 record 가 같이
올라갔고, 앞 세션의 0cm 원본은 덮여 사라졌다. 84296b0 은 업로드만 걸렀다(요약 attempts).

- HospitalRunStore.archivePreviousAlignRun: 정렬 폴더에 다른 세션의 파일이 있으면
  세션 파일(탐색·확인·좌우·좌우 흐름·재부착과 IMU)·align_result.json·업로드 마커를
  `prev_HHmm/`(앞 세션 started_at)으로 옮긴다. 요약 started_at 이 이 세션과 같으면
  손대지 않는다. 요약이 없거나 시각이 없으면 가장 오래된 파일의 수정 시각. 이름이
  겹치면 `_2`. standing 확인 파일은 그날·자세의 것이라 남기고 `standing_confirm`
  판정만 남긴 요약을 다시 써서 뒤의 정렬 요약이 이어받게 한다.
- AnchorAlignView: 첫 파일을 쓰기 직전 세션당 한 번 부른다. [처음부터 다시 정렬] 이
  되돌린다.
- HospitalLabdbUploader.pendingAligns: 각 정렬 폴더 뒤에 `prev_*` 를 붙인다 — 마커가
  같이 옮겨지므로 못 올라간 세션만 남는다.
- AlignLabdbPayload.sessionBase: 보관 폴더에서 올려도 환자·날짜·자세는 정렬 폴더 기준.
- 테스트: HospitalRunStoreAlignArchiveTest(신규 7) 외 pending·sessionBase 케이스.

업로드 필터(84296b0)는 옛 앱이 만든 폴더를 위한 이중 안전장치로 그대로 둔다.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-10-15 17:59:15 +09:00
parent 84296b07b4
commit 839f8d70ac
8 changed files with 289 additions and 8 deletions
@@ -346,6 +346,12 @@ object HospitalRunStore {
* 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 **`align_{자세}/`** 에 둔다 * 정렬 raw 는 임상 데이터와 같은 환자·날짜 폴더 밑 **`align_{자세}/`** 에 둔다
* (2026-10-01 — 자세마다 정렬을 다시 하므로 자세별로 가른다. 같은 `align/` 에 쓰면 * (2026-10-01 — 자세마다 정렬을 다시 하므로 자세별로 가른다. 같은 `align/` 에 쓰면
* Supine 정렬 원시 데이터가 Sitting 정렬로 덮인다). 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 { fun alignDir(patient: String, startedAt: Date, posture: com.medithings.vesiscan.models.ClinicalPosture): File {
val dir = File(runDir(patient, startedAt), alignDirName(posture)) val dir = File(runDir(patient, startedAt), alignDirName(posture))
@@ -366,6 +372,80 @@ object HospitalRunStore {
fun alignPostureSuffix(alignDir: File): String = fun alignPostureSuffix(alignDir: File): String =
alignDir.name.removePrefix("align").takeIf { it.startsWith("_") }.orEmpty() 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 로 저장한다. * 한 위치의 cycle 전부를 CSV 로 저장한다.
* *
@@ -36,7 +36,9 @@ import java.util.Locale
* 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다. * 봐야 할 질문이 "왜 이 위치를 골랐나"라서, 파형만 있으면 답이 안 나온다.
* *
* 단, **이 세션의 파일만**이다. 폴더가 환자·날짜·자세로만 갈려서 같은 날 다시 정렬하면 앞 * 단, **이 세션의 파일만**이다. 폴더가 환자·날짜·자세로만 갈려서 같은 날 다시 정렬하면 앞
* 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15). * 세션의 파일이 남아 있다 — 요약의 `attempts` 로 가른다([isFromThisRun], 2026-10-15). 같은 날부터
* 앱은 새 세션이 첫 파일을 쓰기 전에 앞 세션을 `prev_HHmm/` 으로 옮기므로
* ([HospitalRunStore.archivePreviousAlignRun]) 이 필터는 그 전 앱이 만든 폴더를 위한 이중 안전장치다.
* *
* ``` * ```
* record = 한 위치의 한 cycle * record = 한 위치의 한 cycle
@@ -340,14 +342,16 @@ object AlignLabdbPayload {
* 모양이 아니면(단위시험 임시 폴더) 종전대로 `save_name` 을 쓴다. * 모양이 아니면(단위시험 임시 폴더) 종전대로 `save_name` 을 쓴다.
*/ */
internal fun sessionBase(alignDir: File, saveName: String, summary: JSONObject?): String { 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 root = if (RUN_DIR_RE.matches(dirName)) dirName else saveName.ifBlank { "align" }
val hhmm = summary?.optString("started_at").orEmpty() val hhmm = summary?.optString("started_at").orEmpty()
.let { Regex("""T(\d{2}):(\d{2})""").find(it) } .let { Regex("""T(\d{2}):(\d{2})""").find(it) }
?.let { it.groupValues[1] + it.groupValues[2] } ?.let { it.groupValues[1] + it.groupValues[2] }
// 자세별 정렬 폴더(`align_sitting`)면 그 꼬리를 붙인다 — 같은 날 같은 환자의 자세별 // 자세별 정렬 폴더(`align_sitting`)면 그 꼬리를 붙인다 — 같은 날 같은 환자의 자세별
// 세션이 갈려야 한다(2026-10-01). 옛 `align/` 은 꼬리 없음. // 세션이 갈려야 한다(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" } ?: "") return root + "_align" + posture + (hhmm?.let { "_$it" } ?: "")
} }
@@ -105,11 +105,19 @@ object HospitalLabdbUploader {
/** /**
* 그 환자·날짜 폴더의 미업로드 정렬 폴더 **전부** — 옛 `align/` 과 자세별 `align_{자세}/` * 그 환자·날짜 폴더의 미업로드 정렬 폴더 **전부** — 옛 `align/` 과 자세별 `align_{자세}/`
* (2026-10-01, 자세마다 정렬). 이름순이라 `align`, `align_sitting`, `align_standing`, `align_supine`. * (2026-10-01, 자세마다 정렬). 이름순이라 `align`, `align_sitting`, `align_standing`, `align_supine`.
* 각 정렬 폴더 뒤에 그 밑의 보관된 앞 세션(`prev_HHmm/`, 2026-10-15)이 따른다 — 업로드 마커가
* 세션과 같이 옮겨지므로 못 올라간 세션만 남는다.
*/ */
fun pendingAligns(runDir: File): List<File> = fun pendingAligns(runDir: File): List<File> =
runDir.listFiles() runDir.listFiles()
?.filter { it.isDirectory && com.medithings.vesiscan.services.HospitalRunStore.ALIGN_DIR_RE.matches(it.name) } ?.filter { it.isDirectory && com.medithings.vesiscan.services.HospitalRunStore.ALIGN_DIR_RE.matches(it.name) }
?.sortedBy { 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 -> ?.filter { dir ->
!isAlignUploaded(dir) && !isAlignUploaded(dir) &&
dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true dir.listFiles()?.any { it.isFile && ALIGN_CSV_RE.matches(it.name) } == true
@@ -208,7 +216,7 @@ object HospitalLabdbUploader {
fun uploadAlignAsync(alignDir: File, memo: String = "") { fun uploadAlignAsync(alignDir: File, memo: String = "") {
if (!LabdbCredentials.isRegistered) return if (!LabdbCredentials.isRegistered) return
scope.launch { 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) val (ok, msg) = uploadAlign(alignDir, memo)
uploadingName.value = null uploadingName.value = null
lastMessage.value = if (ok) "정렬 업로드 — $msg" else "정렬 업로드 실패 — $msg" lastMessage.value = if (ok) "정렬 업로드 — $msg" else "정렬 업로드 실패 — $msg"
@@ -175,6 +175,12 @@ fun AnchorAlignView(appState: AppState) {
* labdb 분당 제한(10건)을 정렬 하나가 먹는다. * labdb 분당 제한(10건)을 정렬 하나가 먹는다.
*/ */
var alignDirty by remember { mutableStateOf(false) } var alignDirty by remember { mutableStateOf(false) }
/**
* 이 세션이 폴더에 앞 세션 파일이 남았는지 봤는가. 첫 파일을 쓰기 직전 한 번만 본다 — 폴더가
* 환자·날짜·자세로만 갈려 앞 세션의 파일이 그대로 남기 때문이다
* ([HospitalRunStore.archivePreviousAlignRun], 2026-10-15). [처음부터 다시 정렬] 이 되돌린다.
*/
var archiveChecked by remember { mutableStateOf(false) }
// ── 방광 용적 ──────────────────────────────────────────────────────────── // ── 방광 용적 ────────────────────────────────────────────────────────────
// 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가 // 정렬은 지표(nch·ch3·cap_frac)로 위치를 고르는데, 그 지표가 좋다고 BV 가
@@ -430,6 +436,15 @@ fun AnchorAlignView(appState: AppState) {
// 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서 // 급할 때 이름 없이 재고 나면 20 cycle × 위치 수를 통째로 잃는다. 임상에서
// 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다. // 제일 아까운 것이 이미 받은 데이터라, 이름은 나중에 폴더명으로 맞추면 된다.
val dir = HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture) 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 hasAlignData = true
alignDirty = true alignDirty = true
// 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정. // 판정이 끝난 뒤 같은 위치를 다시 재는 것 = 부착 위치 확인 측정.
@@ -1109,8 +1124,9 @@ fun AnchorAlignView(appState: AppState) {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
TextButton( TextButton(
onClick = { onClick = {
// 앞 시도는 지금 올려 둔다 — 새 시도가 같은 폴더의 파일을 위치마다 // 앞 시도는 지금 올려 둔다. 새 시도가 첫 파일을 쓸 때 앞 시도의 파일은
// 덮어쓰므로, 여기서 안 올리면 그 데이터는 어디에도 남지 않는다. // `prev_HHmm/` 으로 옮겨져 남지만(2026-10-15), 서버에는 지금 보내야 재시도
// 큐가 아니라 바로 올라간다.
if (hasAlignData && alignDirty) { if (hasAlignData && alignDirty) {
HospitalLabdbUploader.uploadAlignAsync( HospitalLabdbUploader.uploadAlignAsync(
HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture)) HospitalRunStore.alignDir(saveName, runStartedAt, appState.clinicalPosture))
@@ -1120,7 +1136,7 @@ fun AnchorAlignView(appState: AppState) {
guide.reset(); cm = 0; last = null; records = emptyList(); error = null guide.reset(); cm = 0; last = null; records = emptyList(); error = null
lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++ lateral.reset(); lrCommit = null; lrAccum = 0; lrRecent.clear(); lrAttempt++
lrRefChannels = null; confirmTiltDeg = null lrRefChannels = null; confirmTiltDeg = null
attempts.clear(); detachedCms.clear() attempts.clear(); detachedCms.clear(); archiveChecked = false
lrConfirmed = false; lrError = null; lrRunning = false lrConfirmed = false; lrError = null; lrRunning = false
disconnectedMidRun = false disconnectedMidRun = false
decidedAtCm = null; sweepStopped = false; confirmDone = false decidedAtCm = null; sweepStopped = false; confirmDone = false
@@ -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<Int> = 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"))
}
}
@@ -476,4 +476,13 @@ class AlignLabdbPayloadTest {
File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8) File(d, "align_result.json").writeText(summaryWithAttempts(0 to 1).toString(), Charsets.UTF_8)
assertNull(AlignLabdbPayload.build(d)) 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))
}
} }
@@ -166,4 +166,20 @@ class HospitalAlignPendingTest {
assertTrue(combos[0].isFile) assertTrue(combos[0].isFile)
assertTrue(combos[0].name.endsWith(".csv")) 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))
}
} }
+1 -1
View File
@@ -280,7 +280,7 @@ IMU 는 2026-09-21 이후 업로드분부터 `sensor.imu` 로 들어옵니다(
| 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 | | 기존 적재분 | `901` 로 올렸던 **72세션 · 1,440레코드**(2026-09-04)는 서버에서 `600` 으로 마이그레이션 완료 |
| `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 | | `902` 정렬 1세션 | 같은 `testId` 로 `601` 재업로드하여 갱신 |
| 앱 반영 | 상수 2개 변경 + 최상위 `subject` 추가 | | 앱 반영 | 상수 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 는 일반 앱의 > 이전 판에 "279건(2026-09-05)"이라고 적혀 있었습니다. **오기입니다** — 279 는 일반 앱의
> 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다. > 미업로드 세션 수였고 병원 적재량과 무관합니다. 실제는 72세션 · 1,440레코드입니다.