fix(labdb): 병원 임상 화면에 등록 카드 · 임상 세션에 최상위 subject

## 왜: 실기기에서 데이터가 조용히 안 올라갔다

2026-09-09 A15 에서 임상 세션 3개(70 cycle)와 정렬 1세트(80 record)를 받았는데 labdb 에
하나도 없었다. 폴더를 열어 보니 **성공 마커도 실패 마커도 없었다** — 업로드를 시도한
기록 자체가 없다.

    ClinicalSessionStore:273   if (creds.isRegistered) uploadAsync(...)
    HospitalLabdbUploader:80   if (!isRegistered) return false to "미등록"

둘 다 isRegistered 뒤에 있어 미등록이면 아무 일도 일어나지 않는다. 그런데 등록 버튼은
임상 측정 홈(ClinicalHomeView)에만 있고, **병원 임상 모드는 홈에서 직접 들어가** 그곳을
거치지 않는다. 게다가 병원 모드의 업로드 카드가 `isRegistered && lastRunDir != null` 로
감싸져 있어, 미등록이면 화면에 **아무것도 없었다.** 조작자는 "이 화면에는 업로드 기능이
없다"로 읽는다.

## LabdbStatusCard — 측정 **전에** 보인다

환자명 위, 화면 제일 앞에 둔다. 20 cycle × 조합을 다 받은 뒤에 알면 그 데이터는 폰에만
남고 방광을 비운 뒤라 다시 잴 수 없다.

    미등록            경고색 + [labdb 등록] 버튼
    승인 대기 pending  경고색 — **"등록했다"와 "올라간다"는 다르다**
    활성 active       초록 — 조합마다 자동 업로드

승인 대기를 초록으로 두지 않은 것이 핵심이다. 버튼을 누른 것만으로 끝났다고 읽으면 같은
일이 반복된다. 진입 때 checkStatus 를 한 번 불러, 승인이 떨어졌는지·기기가 삭제됐는지
(404 면 자격이 비워진다) 그 자리에서 보인다.

업로드 카드는 isRegistered 를 떼고 lastRunDir 만 본다. 미등록일 때도 **몇 건이 못
올라갔는지**는 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로. 다만
버튼은 막는다(미등록으로 누르면 실패 마커만 쌓여 나중에 성공 여부가 헷갈린다).

## 최상위 subject

서버(server.js:650)는 **최상위 `data.subject` 만** 읽어 `sessions.subject` 컬럼에 넣는다.
앱은 `params.subject` 에만 넣고 있었다 — 서버 담당자가 지적한 "기존 72세션의 subject 가
전부 비어 있다"의 원인과 같은 버그다. 그 컬럼이 export 파일명 prefix 와 세션 목록 표시에
쓰이므로, 비면 분석하는 쪽이 testId 를 눈으로 파싱해야 한다.

params 안에도 그대로 둔다. 이미 올라간 세션들이 그 자리에 있어, 빼면 과거·신규를 같은
규칙으로 읽을 수 없다. 600/601 이 이미 같은 처리다.

테스트 121개 통과.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 17:30:24 +09:00
parent 7926b61667
commit 1c979fc26b
3 changed files with 171 additions and 2 deletions
@@ -120,6 +120,14 @@ object LabdbUploader {
return JSONObject().apply {
put("testId", testId)
put("dataType", dataType)
// 서버(server.js:650)는 **최상위 subject 만** 읽어 sessions.subject 컬럼에
// 넣는다 — params 안은 보지 않는다. 그래서 기존 세션의 그 컬럼이 전부 비어
// 있었다(2026-09-09 서버 조사). 그 컬럼이 export 파일명 prefix 와 세션 목록
// 표시에 쓰이므로, 비면 분석하는 쪽이 testId 를 눈으로 파싱해야 한다.
//
// params 안에도 그대로 둔다. 이미 올라간 세션들이 그 자리에 있어, 빼면
// 과거·신규 세션을 같은 규칙으로 읽을 수 없다.
meta.optString("subject").takeIf { it.isNotBlank() }?.let { put("subject", it) }
// 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임).
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
put("sessionName", folderName)
@@ -0,0 +1,147 @@
/*
* LabdbStatusCard — labdb 등록 상태와 등록 버튼. 임상 화면 어디서든 붙인다.
*/
package com.medithings.vesiscan.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Text
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.services.labdb.LabdbClient
import com.medithings.vesiscan.services.labdb.LabdbCredentials
import com.medithings.vesiscan.ui.theme.*
import kotlinx.coroutines.launch
/**
* labdb 등록 상태 — **미등록이면 반드시 눈에 띄어야 한다.**
*
* ## 왜 이 카드가 필요한가
* 종전에는 등록 버튼이 임상 측정 홈([views.clinical.ClinicalHomeView])에만 있었다.
* 그런데 병원 임상 모드는 홈 화면에서 **직접** 들어가므로 그곳을 거치지 않는다. 게다가
* 병원 모드의 업로드 카드가 `isRegistered` 로 감싸져 있어, 미등록이면 카드가 아예 안
* 보였다 — 조작자는 "이 화면에는 업로드 기능이 없다"로 읽는다.
*
* 그 결과 2026-09-09 실기기에서 임상 세션 3개와 정렬 1세트를 받았는데 **업로드 시도
* 기록조차 없었다**(성공·실패 마커가 둘 다 없었다). 자동 업로드가 `isRegistered` 뒤에
* 있어 아무 일도 일어나지 않은 것이다.
*
* 측정을 시작하기 **전에** 알아야 한다. 20 cycle × 조합을 다 받은 뒤에 알면 그 데이터는
* 폰에만 남고, 방광을 비운 뒤라 다시 잴 수도 없다.
*
* ## 상태가 셋이다
* | | 뜻 | 업로드 |
* |---|---|---|
* | 미등록 | apiKey 없음 | **안 됨** — 시도조차 안 한다 |
* | 승인 대기(`pending`) | 등록됨, 관리자 승인 전 | 안 됨 — 서버가 거절 |
* | 활성(`active`) | 승인됨 | 됨 |
*
* **"등록했다"와 "올라간다"는 다르다.** 등록 직후는 `pending` 이라, 버튼을 누른 것만으로
* 끝났다고 읽으면 같은 일이 반복된다. 그래서 승인 대기도 경고 색으로 둔다.
*/
@Composable
fun LabdbStatusCard(
modifier: Modifier = Modifier,
/** 측정 중에는 막는다 — 등록 요청이 BLE 정보를 읽어 가므로 루프와 겹치지 않게 한다. */
enabled: Boolean = true,
) {
val context = LocalContext.current
val scope = rememberCoroutineScope()
var registered by remember { mutableStateOf(LabdbCredentials.isRegistered) }
var status by remember { mutableStateOf(LabdbCredentials.lastStatus) }
var busy by remember { mutableStateOf(false) }
var message by remember { mutableStateOf<String?>(null) }
// 등록되어 있으면 진입 때 상태를 한 번 확인한다. 승인이 떨어졌는지 조작자가 알아야
// 하고, 기기가 삭제됐으면(404) checkStatus 가 자격을 비우므로 그 사실도 즉시 보인다.
LaunchedEffect(Unit) {
if (!LabdbCredentials.isRegistered) return@LaunchedEffect
busy = true
runCatching { LabdbClient.checkStatus() }
.onSuccess { status = it.status }
.onFailure { message = it.message }
registered = LabdbCredentials.isRegistered
busy = false
}
val active = registered && status == "active"
val tone = if (active) MlSuccess else MlWarning
Column(
modifier.fillMaxWidth()
.background(tone.copy(alpha = 0.10f), RoundedCornerShape(12.dp))
.border(1.dp, tone.copy(alpha = 0.5f), RoundedCornerShape(12.dp))
.padding(12.dp)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Column(Modifier.weight(1f)) {
Text(
when {
!registered -> "labdb 미등록 — 업로드되지 않습니다"
active -> "labdb 연결됨"
status == "pending" -> "labdb 승인 대기 — 아직 업로드되지 않습니다"
status == "revoked" -> "labdb 권한 해제됨 — 업로드되지 않습니다"
else -> "labdb 상태 ${status ?: "확인 안 됨"}"
},
fontSize = 14.sp, fontWeight = FontWeight.Bold, color = tone,
)
Text(
when {
!registered ->
"지금 측정하면 데이터가 폰에만 남습니다. 아래 버튼으로 이 기기를 등록하세요."
active -> "조합이 끝날 때마다 자동으로 올라갑니다."
status == "pending" ->
"관리자가 이 기기를 승인해야 올라갑니다. 승인 전 측정분은 폰에 쌓여 있다가 " +
"승인 뒤 [지금 업로드] 로 한꺼번에 보낼 수 있습니다."
else -> "관리자에게 문의하세요. 측정분은 폰에 남습니다."
},
fontSize = 11.sp, color = MlSecondaryText,
)
LabdbCredentials.deviceId?.let {
Text("deviceId $it", fontSize = 10.sp, color = MlSecondaryText)
}
}
if (!registered) {
Spacer(Modifier.width(8.dp))
Button(
onClick = {
if (busy) return@Button
busy = true; message = null
scope.launch {
val version = runCatching {
context.packageManager
.getPackageInfo(context.packageName, 0).versionName
}.getOrNull() ?: "unknown"
runCatching { LabdbClient.register(context, version) }
.onSuccess {
registered = true
status = it.status
// 서버 문구를 그대로 보여 준다. 승인 절차가 바뀌어도
// 앱이 옛 안내를 하지 않는다.
message = it.message.ifBlank { "등록됨 — 승인 대기" }
}
.onFailure { message = "등록 실패 — ${it.message}" }
busy = false
}
},
enabled = enabled && !busy,
colors = ButtonDefaults.buttonColors(containerColor = MlPrimary),
shape = RoundedCornerShape(10.dp),
) { Text(if (busy) "등록 중…" else "labdb 등록", color = Color.White, fontSize = 12.sp) }
}
}
message?.let {
Spacer(Modifier.height(6.dp))
Text(it, fontSize = 11.sp, color = MlSecondaryText)
}
}
}
@@ -41,6 +41,7 @@ import androidx.compose.ui.unit.sp
import com.medithings.vesiscan.managers.AnchorBasis
import com.medithings.vesiscan.managers.ClinicalBv
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
import com.medithings.vesiscan.ui.components.LabdbStatusCard
import com.medithings.vesiscan.ui.components.SixChannelWaveformGrid
import com.medithings.vesiscan.AppScreen
import com.medithings.vesiscan.AppState
@@ -412,6 +413,14 @@ fun HospitalModeView(appState: AppState) {
)
Spacer(Modifier.height(12.dp))
// labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차
// 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다
// isRegistered 뒤에 있다), 종전에는 업로드 카드가 isRegistered 로 감싸져
// 있어 미등록일 때 화면에 아무것도 없었다. 20 cycle × 조합을 받은 뒤에
// 알면 방광을 비운 뒤라 다시 잴 수 없다.
LabdbStatusCard(enabled = !running)
Spacer(Modifier.height(12.dp))
AnchorCard(
anchorCm = appState.anchorCm,
basis = appState.anchorBasis,
@@ -558,7 +567,10 @@ fun HospitalModeView(appState: AppState) {
// 조합이 끝날 때마다 자동으로 올라간다. 인터넷이 끊겨 있었으면 여기
// 남아 있다가 이 버튼으로 한꺼번에 올린다 — 병원 무선망이 불안정한
// 곳이 많아 "나중에 올리기"가 예외가 아니라 정상 경로다.
if (LabdbCredentials.isRegistered && lastRunDir != null) {
// isRegistered 로 감싸지 않는다. 미등록일 때도 **몇 건이 못 올라갔는지**는
// 보여야 한다 — 승인 뒤 [지금 업로드] 로 한꺼번에 보내야 하므로, 그 수를
// 모르면 무엇을 기다리는지 알 수 없다.
if (lastRunDir != null) {
Spacer(Modifier.height(12.dp))
Box(
Modifier.fillMaxWidth()
@@ -591,7 +603,9 @@ fun HospitalModeView(appState: AppState) {
uploadBusy = false
}
},
enabled = !uploadBusy,
// 미등록 상태로 누르면 전부 실패 마커가 쌓이고, 그
// 마커 때문에 나중에 성공 여부를 헷갈리게 된다.
enabled = !uploadBusy && LabdbCredentials.isRegistered,
modifier = Modifier.fillMaxWidth().height(46.dp),
shape = RoundedCornerShape(12.dp),
) {