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:
@@ -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),
|
||||
) {
|
||||
|
||||
Reference in New Issue
Block a user