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:
@@ -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
|
||||||
|
|||||||
+148
@@ -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))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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레코드입니다.
|
||||||
|
|||||||
Reference in New Issue
Block a user