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 {
|
return JSONObject().apply {
|
||||||
put("testId", testId)
|
put("testId", testId)
|
||||||
put("dataType", dataType)
|
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로 사용 (날짜 포함이면 중복 안 붙임).
|
// 서버는 sessionName을 export 파일명의 base로 사용 (날짜 포함이면 중복 안 붙임).
|
||||||
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
|
// 풀 폴더명을 그대로 보내야 분석 PC 다운로드 파일명과 앱 측 파일명이 일치.
|
||||||
put("sessionName", folderName)
|
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.AnchorBasis
|
||||||
import com.medithings.vesiscan.managers.ClinicalBv
|
import com.medithings.vesiscan.managers.ClinicalBv
|
||||||
import com.medithings.vesiscan.ui.components.ChannelDetachPanel
|
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.ui.components.SixChannelWaveformGrid
|
||||||
import com.medithings.vesiscan.AppScreen
|
import com.medithings.vesiscan.AppScreen
|
||||||
import com.medithings.vesiscan.AppState
|
import com.medithings.vesiscan.AppState
|
||||||
@@ -412,6 +413,14 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
)
|
)
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
|
// labdb 등록 상태. **측정 전에** 보여야 한다 — 미등록이면 업로드 시도조차
|
||||||
|
// 일어나지 않는데(ClinicalSessionStore·HospitalLabdbUploader 가 둘 다
|
||||||
|
// isRegistered 뒤에 있다), 종전에는 업로드 카드가 isRegistered 로 감싸져
|
||||||
|
// 있어 미등록일 때 화면에 아무것도 없었다. 20 cycle × 조합을 받은 뒤에
|
||||||
|
// 알면 방광을 비운 뒤라 다시 잴 수 없다.
|
||||||
|
LabdbStatusCard(enabled = !running)
|
||||||
|
Spacer(Modifier.height(12.dp))
|
||||||
|
|
||||||
AnchorCard(
|
AnchorCard(
|
||||||
anchorCm = appState.anchorCm,
|
anchorCm = appState.anchorCm,
|
||||||
basis = appState.anchorBasis,
|
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))
|
Spacer(Modifier.height(12.dp))
|
||||||
Box(
|
Box(
|
||||||
Modifier.fillMaxWidth()
|
Modifier.fillMaxWidth()
|
||||||
@@ -591,7 +603,9 @@ fun HospitalModeView(appState: AppState) {
|
|||||||
uploadBusy = false
|
uploadBusy = false
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !uploadBusy,
|
// 미등록 상태로 누르면 전부 실패 마커가 쌓이고, 그
|
||||||
|
// 마커 때문에 나중에 성공 여부를 헷갈리게 된다.
|
||||||
|
enabled = !uploadBusy && LabdbCredentials.isRegistered,
|
||||||
modifier = Modifier.fillMaxWidth().height(46.dp),
|
modifier = Modifier.fillMaxWidth().height(46.dp),
|
||||||
shape = RoundedCornerShape(12.dp),
|
shape = RoundedCornerShape(12.dp),
|
||||||
) {
|
) {
|
||||||
|
|||||||
Reference in New Issue
Block a user