환자를 골라 태그에 값이 들어간다 — 데이터 계층만 있던 것을 화면에 붙인다

전 커밋까지 환자 조회·시각 판정·권한 규칙은 다 있었지만 화면에 붙은 것이
하나도 없었다. 미리보기를 열면 환자 선택이 없고, 태그는 전부 사유만 찍혔다.

## 만든 사슬

PatientPickerDialogView(모달) → PatientContextStore(문맥 5행)
→ PatientTagResolver(태그 → 컬럼) → PreviewWindow.UsePatient → 종이

모달로 둔 이유: 30초 안에 끝나는 자족적인 일이고 권한 문이 걸린 동작이다.
사이드 패널로 두면 실제 환자 정보를 꺼내는 창이 항상 열려 있게 되는데
이 창은 열려 있는 것 자체가 비용이다.

환자와 내원을 한 화면에 둔다. 고르는 대상은 환자가 아니라 내원이다 —
문맥 5행이 전부 ComNum + AdpDtm 으로 읽히므로 환자만 골라서는 아무것도 못 읽는다.

기준 시점(마지막/처음)을 화면에 노출한다. 입원 내원의 진료과·병실·자격 값을
실제로 바꾸는 값이라 숨기면 값이 왜 다른지 설명할 수 없다.

문맥 읽기를 창 안에서 한다. 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고
그때는 이미 고른 것이 사라진 뒤다.

## 매핑은 확인된 6종만

PAT_이름 / PAT_차트번호 / PAT_주민번호 / PAT_휴대전화 / PAT_전화번호 / PAT_주소.
전부 레거시 조회 SQL 의 SELECT 목록에서 컬럼을 직접 확인한 것이다.

PAT_나이·PAT_성별은 넣지 않았다 — UDF_GETAGE·UDF_GETSEX 를 거치는 값이라
컬럼을 짐작해 넣으면 값이 조용히 틀린다. 종이에 그럴듯한 값이 찍히면
아무도 의심하지 않으므로 빈칸보다 나쁘다.

안 옮긴 환자 태그는 "아직 옮기지 않았습니다"라고 말한다.
"DB 접속이 없습니다"로 뭉개면 환자를 골랐는데도 그 사유가 나와 접속을 의심하게 된다.

## 문맥 조회를 한 번에 다섯 번 읽는다

레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다(각각 별도 왕복).
태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.

## 개인정보

- 감사 로그는 고른 직후 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
- 로그에 환자 이름을 남기지 않는다(내원번호로 추적 가능하고, 로그가
  개인정보를 들고 있으면 로그 자체가 위험물이 된다).
- 진단 리포트도 값 대신 길이만 적는다.
- 권한 문은 수정(TK_MODIFY)과 같은 범위다. 넓히면 서식생성기가 환자 조회 도구가 된다.
- 화면에서 감사 기록 사실을 말한다.

## 게이트가 잡은 것 셋

① --db-patient 가 "전건 통과"인데 ⑧~⑬ 이 통째로 SKIP 이었다.
   첫 환자에게 내원이 0건이라 문맥 조회를 한 번도 안 돌린 채 초록불이었다.
   내원 있는 환자를 30명까지 훑고, 못 찾으면 SKIP 이 아니라 FAIL 로 바꿨다.

② 그렇게 찾은 내원에서 CodInf·CoiInf·CowInf 가 셋 다 0행이었다.
   0 을 적어 두는 것으로는 "조건이 어긋났다"와 "행이 없다"를 못 가른다.
   조건 없는 원본 행을 함께 찍게 하니 후자였다(1988년 내원, 행 자체가 없음).

③ 그러면 기간 조회 세 갈래는 아직 한 번도 행을 돌려준 적이 없다.
   안 돌아본 갈래는 통과한 갈래가 아니므로, 각 표에서 직접 내원을 찾아 들어가
   BETWEEN·MtiSeq 조건이 실제로 행을 뽑는 것까지 확인하게 했다(⑮).

--edit-smoke 판정 7건 추가. 전부 대조군이 있다 —
환자 없이 같은 종이를 찍어 사유가 나오는 것을 먼저 확인한 뒤 값으로 바뀌는 것을 본다.
미리보기 창 배선도 따로 본다(붙이면 값, 떼면 사라짐). 그래서 고르는 일과
붙이는 일을 나눠 두었다 — 모달에 묻어 두면 "골랐는데 종이가 그대로"를 자동으로 못 잡는다.

## 게이트

- dotnet test 312/312
- --edit-smoke 실패 0 (환자·미리보기 판정 7건 추가)
- --dialog-shots FAIL 0 (08b-patient-picker 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 (변화 없음)
- --db-smoke 1,271건 diff 0
- --db-patient ①~⑮ 전건 통과 (기간 조회 3갈래 실제 통과 확인)
- --db-modify-smoke S999 8/8 (⑤ 는 E_SctMst 0행으로 여전히 SKIP)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-18 15:13:48 +09:00
co-authored by Claude Opus 5
parent be9889ed4e
commit 88dab2edcf
11 changed files with 1008 additions and 6 deletions
@@ -0,0 +1,128 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다.
/// 각 행은 컬럼명 → 값 사전이다. 태그가 <c>&lt;&lt;Patient.PatInfDR.item("PatNam")&gt;&gt;</c> 처럼
/// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다.
/// </summary>
public sealed record PatientContext(
decimal ComNum,
string AdpDtm,
IReadOnlyDictionary<string, string> PatInf,
IReadOnlyDictionary<string, string> ComInf,
IReadOnlyDictionary<string, string> CodInf,
IReadOnlyDictionary<string, string> CoiInf,
IReadOnlyDictionary<string, string> CowInf)
{
/// <summary>행 하나에서 컬럼 값 — 없으면 빈 문자열</summary>
public static string Value(IReadOnlyDictionary<string, string> row, string column)
=> row.TryGetValue(column, out var value) ? value : string.Empty;
}
/// <summary>
/// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> 대응.
///
/// <b>다섯 행이 전부 적용일시를 키로 쓴다.</b> 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf),
/// 병동·병실(P_CowInf) 은 <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다 —
/// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다.
/// 그래서 시각 판정은 <see cref="Core.Catalog.VisitMoment"/> 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다.
///
/// <b>레거시와 다른 점</b>: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다
/// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 <b>한 번에 다섯 번</b> 읽고 들고 있는다 —
/// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.
///
/// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다.
/// </summary>
public sealed class PatientContextStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public PatientContextStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>이 내원의 문맥 — 내원 자체가 없으면 null</summary>
public PatientContext? Load(decimal comNum, Core.Catalog.VisitEdge edge = Core.Catalog.VisitEdge.Last)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var comInf = Row(connection, "SELECT * FROM P_ComInf WHERE ComNum = :k", ("k", comNum));
if (comInf.Count == 0)
{
return null;
}
var window = new Core.Catalog.VisitWindow(
PatientContext.Value(comInf, "COMPATTYP") == "I",
PatientContext.Value(comInf, "COMACPDTM"),
PatientContext.Value(comInf, "COMLEVDTM"),
string.Empty, string.Empty);
// 자격 구간은 P_CoiInf 가 갖고 있다 — 시각을 정하려면 먼저 읽어야 하고,
// 그 읽기 자체는 시각을 쓰지 않는다(MtiSeq 0 의 최신 행).
var coiSeed = Row(connection,
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
+ " ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum));
window = window with
{
CoverageFrom = PatientContext.Value(coiSeed, "COISTRDTE"),
CoverageTo = PatientContext.Value(coiSeed, "COIENDDTE"),
};
var adpDtm = Core.Catalog.VisitMoment.Decide(window, edge, ServerClock.Read(connection, null).Minute12);
var chtNum = PatientContext.Value(comInf, "COMCHTNUM");
var patInf = Row(connection, "SELECT * FROM P_PatInf WHERE TRIM(PatChtNum) = :c", ("c", chtNum.Trim()));
var codInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
("k", comNum), ("t", adpDtm));
var coiInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
+ " AND :d BETWEEN CoiStrDte AND CoiEndDte ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1",
("k", comNum), ("d", Day(adpDtm)));
var cowInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CowInf WHERE CowComNum = :k"
+ " AND :t BETWEEN CowStrDtm AND CowEndDtm ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
("k", comNum), ("t", adpDtm));
return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf);
}
/// <summary>
/// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
/// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
/// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다.
/// </summary>
private static Dictionary<string, string> Row(OracleConnection connection, string sql,
params (string Name, object Value)[] parameters)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
command.Parameters.Add(new OracleParameter(name, value));
}
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return result;
}
for (var i = 0; i < reader.FieldCount; i++)
{
// CHAR 고정폭 잔여 공백을 여기서 한 번 걷는다 — 태그마다 걷으면 빠뜨린다
result[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
}
return result;
}
private static string Day(string stamp) => stamp.Length >= 8 ? stamp[..8] : stamp;
#endregion
}
+20 -1
View File
@@ -26,7 +26,26 @@ public sealed record PatientSummary(
/// <summary>내원 1건</summary>
public sealed record VisitSummary(
decimal ComNum, string Kind, string AcceptedAt, string LeftAt,
string Department, string Doctor, string Insurance, string State);
string Department, string Doctor, string Insurance, string State)
{
/// <summary>접수일시 읽기 좋게 — 목록에서 12자리 숫자를 사람이 읽으려면 자리를 세어야 한다</summary>
public string AcceptedAtText => Readable(AcceptedAt);
/// <summary>퇴원일시 읽기 좋게. 재원 중이면 빈칸이다</summary>
public string LeftAtText => Readable(LeftAt);
/// <summary>
/// YYYYMMDDHHMM → "2026-08-18 09:30". 길이가 다르면 <b>손대지 않고 그대로</b> 보여 준다 —
/// 잘라서 그럴듯하게 만들면 데이터가 이상한 것을 화면이 감춘다.
/// </summary>
private static string Readable(string stamp)
{
var value = (stamp ?? string.Empty).Trim();
return value.Length == 12
? $"{value[..4]}-{value[4..6]}-{value[6..8]} {value[8..10]}:{value[10..12]}"
: value;
}
}
/// <summary>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.