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

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

## 만든 사슬

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> /// <summary>내원 1건</summary>
public sealed record VisitSummary( public sealed record VisitSummary(
decimal ComNum, string Kind, string AcceptedAt, string LeftAt, 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> /// <summary>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회. /// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
@@ -131,6 +131,20 @@ internal sealed class FormDesignDataBusiness
public static string ModifyDenyReason() => ModifyPolicy.DenyReason( public static string ModifyDenyReason() => ModifyPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate); UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary>
/// 이 사용자가 <b>실제 환자</b>로 미리보기를 할 수 있는가.
///
/// 수정과 같은 문을 쓴다 — 서식생성기에서 환자 정보를 꺼내 보는 것은 진료 행위가 아니라
/// 서식이 맞게 뽑히는지 확인하는 일이고, 그 권한은 레거시가 전산실에만 열어 둔 것과 같은 범위다.
/// 여기를 넓히면 서식생성기가 <b>환자 조회 도구</b>가 된다.
/// </summary>
public static bool CanPreviewPatient() => PatientPreviewPolicy.CanPreviewPatient(
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
HospitalStartDate);
public static string PreviewPatientDenyReason() => PatientPreviewPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary> /// <summary>
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다. /// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다. /// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
+209 -3
View File
@@ -13,6 +13,18 @@ namespace SheetMe.Designer.Diagnostics;
/// </summary> /// </summary>
public static class DbSmoke public static class DbSmoke
{ {
#region Nested Types
/// <summary>
/// 아무것도 해석하지 않는 사슬 꼬리 — 환자 해석기만 판정하려면 뒤가 조용해야 한다.
/// 세션 해석기를 꼬리로 쓰면 시각 태그가 값이 되어 "환자 해석기가 됐다"와 섞인다.
/// </summary>
private sealed class NullTagResolver : SheetMe.Core.Catalog.ITagValueResolver
{
public SheetMe.Core.Catalog.TagValue Resolve(string tag)
=> new(false, "해석기 없음");
}
#endregion
#region Methods #region Methods
/// <summary>읽기 전용 일괄 왕복 스모크</summary> /// <summary>읽기 전용 일괄 왕복 스모크</summary>
public static int RunReadSmoke(string reportPath, int maxSheets) public static int RunReadSmoke(string reportPath, int maxSheets)
@@ -1874,8 +1886,21 @@ public static class DbSmoke
var capped = store.Search(PatientSearchKind.Name, term, max: 3); var capped = store.Search(PatientSearchKind.Name, term, max: 3);
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건"); Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
// 내원 목록 — 검색으로 찾은 첫 환자로 // 내원 목록 — <b>내원이 있는</b> 환자를 찾아 들어간다.
var people = store.Search(PatientSearchKind.Name, term, max: 1); // 처음에는 첫 환자만 봤는데 그 환자에게 내원이 0건이라 ⑧~⑬ 이 통째로 SKIP 되고도
// 리포트는 "전건 통과"였다. 아무것도 확인하지 않은 통과는 통과가 아니다.
var people = store.Search(PatientSearchKind.Name, term, max: 30);
var probed = 0;
var visits = new List<VisitSummary>();
foreach (var candidate in people)
{
probed++;
visits = store.ListVisits(candidate.ChtNum);
if (visits.Count > 0)
{
break;
}
}
if (people.Count == 0) if (people.Count == 0)
{ {
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가" lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
@@ -1883,13 +1908,133 @@ public static class DbSmoke
} }
else else
{ {
var visits = store.ListVisits(people[0].ChtNum); lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)");
lines.Add($" 내원 {visits.Count}건"); lines.Add($" 내원 {visits.Count}건");
Check("⑥ 내원 목록이 예외 없이 돈다", true); Check("⑥ 내원 목록이 예외 없이 돈다", true);
// 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다 // 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다
Check("⑦ 같은 내원이 중복되지 않는다", Check("⑦ 같은 내원이 중복되지 않는다",
visits.Select(v => v.ComNum).Distinct().Count() == visits.Count, visits.Select(v => v.ComNum).Distinct().Count() == visits.Count,
$"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}"); $"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}");
// ── 환자 문맥 ──
// 여기가 이 조사의 목적이다. 위 ①~⑦ 은 목록이 뜨는지만 본다.
// 문맥 조회의 컬럼 이름은 <b>추측으로 쓴 것</b>이라 실DB 아니면 확인할 방법이 없고,
// 틀리면 예외가 아니라 <b>빈 값</b>이 나온다 — 화면에서는 "환자가 값이 없다"로 보인다.
if (visits.Count == 0)
{
// 여기까지 오면 훑은 환자 전부가 내원 0건이다. 검색어를 바꿔 다시 돌려야 한다 —
// 이 줄을 통과로 세면 문맥 조회를 <b>한 번도 확인하지 않은 채</b> 초록불이 된다.
Check($"⑧~⑬ 환자 문맥 — 훑은 {probed}명 모두 내원 0건이라 확인 불가", false,
"다른 검색어로 다시 돌릴 것");
}
else
{
var target = visits[0].ComNum;
var contexts = new PatientContextStore(ConfigService.Current.ConnectionString);
var context = contexts.Load(target);
Check("⑧ 문맥 5행 조회가 예외 없이 돈다", context is not null,
$"내원 {target:F0} 을 읽지 못했다(목록에는 있는데 다시 읽으면 없다)");
if (context is { } ctx)
{
lines.Add($" 내원 {target:F0} 적용일시={ctx.AdpDtm}"
+ $" / PatInf {ctx.PatInf.Count}열, ComInf {ctx.ComInf.Count}열,"
+ $" CodInf {ctx.CodInf.Count}열, CoiInf {ctx.CoiInf.Count}열, CowInf {ctx.CowInf.Count}열");
// 컬럼 이름을 실제로 확인한다 — 오타면 여기서 걸린다
var missing = new[] { "ComChtNum", "ComAcpDtm", "ComPatTyp", "ComLevDtm" }
.Where(c => !ctx.ComInf.ContainsKey(c)).ToList();
Check("⑨ P_ComInf 컬럼 이름이 맞다", missing.Count == 0,
$"없는 컬럼: {string.Join(", ", missing)}");
// 이 조인이 깨지면 환자 이름·차트번호 태그가 전부 빈다
Check("⑩ 내원의 차트번호로 P_PatInf 를 찾는다", ctx.PatInf.Count > 0,
$"차트번호 '{PatientContext.Value(ctx.ComInf, "ComChtNum")}' 로 환자를 못 찾았다"
+ " (P_ComInf.ComChtNum 과 P_PatInf.PatChtNum 의 자릿수·공백 규약이 다를 수 있다)");
// 적용일시가 12자리가 아니면 BETWEEN 이 전부 0행이 된다
Check("⑪ 적용일시가 12자리다", ctx.AdpDtm.Length == 12, $"'{ctx.AdpDtm}'");
// 기간 조회 3행. 0행은 실패가 아닐 수 있다(외래는 병실이 없다) —
// 그래서 0을 그냥 적지 않고 <b>이유를 캔다</b>. 조건을 뺀 원본 행을 함께 찍으면
// "그 내원에 애초에 행이 없다"와 "행은 있는데 시각·MtiSeq 조건이 어긋났다"가 갈린다.
// 0 을 이유 없이 적어 두면 리포트가 초록불인 채로 태그가 통째로 비게 된다.
lines.Add($" 기간 조회: CodInf {(ctx.CodInf.Count > 0 ? "" : "0")},"
+ $" CoiInf {(ctx.CoiInf.Count > 0 ? "" : "0")},"
+ $" CowInf {(ctx.CowInf.Count > 0 ? "" : "0")}");
var conn = ConfigService.Current.ConnectionString;
if (ctx.CodInf.Count == 0)
{
lines.Add(" [진단] P_CodInf 조건 없는 원본 행:");
foreach (var row in ExplainPeriod(conn, target,
"SELECT CodMtiSeq, CodStrDtm, CodEndDtm FROM P_CodInf"
+ " WHERE CodComNum = :k ORDER BY CodStrDtm"))
{
lines.Add($" {row}");
}
}
if (ctx.CoiInf.Count == 0)
{
lines.Add(" [진단] P_CoiInf 조건 없는 원본 행:");
foreach (var row in ExplainPeriod(conn, target,
"SELECT CoiMtiSeq, CoiStrDte, CoiEndDte FROM P_CoiInf"
+ " WHERE CoiComNum = :k ORDER BY CoiStrDte"))
{
lines.Add($" {row}");
}
}
Check("⑭ CodInf 0행이면 그 이유가 원본 행으로 설명된다", true,
"위 [진단] 줄을 읽어 시각·MtiSeq 조건을 판단할 것");
// ── 기간 조회 세 갈래를 <b>실제로 한 번은 통과시킨다</b> ──
// 위 내원은 CodInf·CoiInf·CowInf 가 전부 0행이었다. 즉 이 세 SQL 의
// BETWEEN·MtiSeq 조건은 아직 <b>한 번도 행을 돌려준 적이 없다</b>.
// 안 돌아본 갈래는 통과한 갈래가 아니다 — 그래서 각 표에서 직접 내원을 찾아 들어간다.
foreach (var (label, table, key, stamp) in new[]
{
("CodInf(진료과·담당의)", "P_CodInf", "CodComNum", "CodStrDtm"),
("CoiInf(보험 자격)", "P_CoiInf", "CoiComNum", "CoiStrDte"),
("CowInf(병동·병실)", "P_CowInf", "CowComNum", "CowStrDtm"),
})
{
var owner = FirstComNum(conn, table, key, stamp);
if (owner is null)
{
Check($"⑮ {label} 기간 조회 — {table} 에 행이 없어 확인 불가", false,
"이 DB 로는 이 갈래를 확인할 수 없다");
continue;
}
var sample = contexts.Load(owner.Value);
var got = table switch
{
"P_CodInf" => sample?.CodInf.Count ?? 0,
"P_CoiInf" => sample?.CoiInf.Count ?? 0,
_ => sample?.CowInf.Count ?? 0,
};
Check($"⑮ {label} 기간 조회가 실제로 행을 돌려준다", got > 0,
$"내원 {owner.Value:F0} 적용일시={sample?.AdpDtm ?? "( )"} → {got}열"
+ " (0이면 BETWEEN 시각이나 MtiSeq 조건이 어긋난 것이다)");
}
// 실제 문맥으로 태그가 값이 되는가 — 가상 문맥(--edit-smoke)과 짝이 되는 판정이다
var resolver = new Services.PatientTagResolver(ctx, new NullTagResolver());
var chart = resolver.Resolve("PAT_차트번호");
var name = resolver.Resolve("PAT_이름");
Check("⑫ 실제 문맥으로 PAT_차트번호·PAT_이름 이 값이 된다",
chart.Resolved && name.Resolved,
$"차트번호 {(chart.Resolved ? "" : chart.Text)} / 이름 {(name.Resolved ? "" : name.Text)}");
// 값 자체는 개인정보라 리포트에 쓰지 않는다 — 길이만 적는다
lines.Add($" 값 길이: 차트번호 {chart.Text.Length}자, 이름 {name.Text.Length}자"
+ " (값은 개인정보라 기록하지 않는다)");
// 대조군 — 매핑에 없는 환자 태그는 값이 되지 않아야 한다.
// 이게 없으면 ⑫ 는 "해석기가 아무 태그나 통과시킨다"와 구분되지 않는다.
var unmapped = resolver.Resolve("PAT_나이");
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
}
}
} }
lines.Add(string.Empty); lines.Add(string.Empty);
@@ -1903,6 +2048,67 @@ public static class DbSmoke
} }
} }
/// <summary>
/// 이 표에 행을 가진 내원 하나 — 기간 조회 갈래를 실제로 통과시켜 보려면 재료가 필요하다.
/// 최근 것을 고른다(오래된 내원은 구간이 이미 닫혀 있어 판정이 덜 대표적이다).
/// <b>표 이름·컬럼 이름은 호출부의 리터럴만 들어온다</b> — 사용자 입력이 닿는 경로가 아니다.
/// </summary>
private static decimal? FirstComNum(string connectionString, string table, string key, string stamp)
{
try
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT {key} FROM (SELECT {key} FROM {table} ORDER BY {stamp} DESC) WHERE ROWNUM = 1";
var value = command.ExecuteScalar();
return value is null or DBNull ? null : Convert.ToDecimal(value);
}
catch
{
return null;
}
}
/// <summary>
/// 기간 조회가 0행일 때 <b>왜</b> 0행인지 보여 준다 — 조건을 뺀 원본 행의 구간을 그대로 찍는다.
/// 값이 아니라 구간만 읽으므로 개인정보를 꺼내지 않는다.
/// </summary>
private static List<string> ExplainPeriod(string connectionString, decimal comNum, string sql)
{
var rows = new List<string>();
try
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", comNum));
using var reader = command.ExecuteReader();
while (reader.Read())
{
var cells = new string[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
cells[i] = $"{reader.GetName(i)}="
+ (reader.IsDBNull(i) ? "(null)" : $"'{reader.GetValue(i).ToString()!.Trim()}'");
}
rows.Add(string.Join(" ", cells));
}
if (rows.Count == 0)
{
rows.Add("(원본 행 자체가 없다 — 조건 문제가 아니라 이 내원에 그 정보가 없는 것이다)");
}
}
catch (Exception ex)
{
rows.Add($"진단 실패: {ex.GetType().Name}: {ex.Message}");
}
return rows;
}
public static int RunModifySmoke(string shtCod, string reportPath) public static int RunModifySmoke(string shtCod, string reportPath)
{ {
var lines = new List<string>(); var lines = new List<string>();
@@ -149,6 +149,9 @@ public static class DialogShots
("06-font-manager", () => new Views.FontManagerDialogView(designer)), ("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")), ("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)), ("08-preview", () => new Views.PreviewWindow(designer)),
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다. // 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다. // 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")), ("09-inspector-label", () => HostInspector(designer, "Label1")),
@@ -453,6 +453,126 @@ public static class EditSmoke
&& withResolver.Contains("환자", StringComparison.Ordinal) && withResolver.Contains("환자", StringComparison.Ordinal)
&& withResolver.Contains("[", StringComparison.Ordinal), && withResolver.Contains("[", StringComparison.Ordinal),
withResolver); withResolver);
// ── 환자 문맥을 붙였을 때 ──
// 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고,
// 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다").
// DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다.
static SheetMe.Data.Stores.PatientContext Fake(string chart, string name)
{
var patInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATCHTNUM"] = chart,
["PATNAM"] = name,
};
var blank = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new SheetMe.Data.Stores.PatientContext(
90001m, "202608181500", patInf, blank, blank, blank, blank);
}
var patientDoc = new FormDocument();
var patientPage = new FormPage { Width = 720, Height = 856 };
patientDoc.Pages.Add(patientPage);
// 넷을 한 종이에 둔다: 매핑된 것 둘, 매핑 안 된 환자 태그 하나, 시각 태그 하나.
// 하나만 두면 "값이 나온다"만 보고 나머지 갈래가 조용히 깨져도 모른다.
var probes = new[]
{
("차트", "PAT_차트번호"), ("이름", "PAT_이름"),
("나이", "PAT_나이"), ("시각", "ETC_현재일자"),
};
for (var i = 0; i < probes.Length; i++)
{
var probe = new ControlElement
{
Type = "TextBox", Id = probes[i].Item1,
Bounds = new LayoutRect { X = 40, Y = 40 + (i * 40), W = 260, H = 20 },
};
probe.Props.SetText("Text", probes[i].Item2);
probe.Props.SetText("DataInterfaceTag", probes[i].Item2);
patientPage.Controls.Add(probe);
}
var patientVm = new DesignerViewModel(patientDoc);
string PatientPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver)
{
var visual = (System.Windows.FrameworkElement)
Services.PrintService.BuildPageVisual(patientVm.Pages[0], true, resolver);
visual.Measure(new System.Windows.Size(720, 856));
visual.Arrange(new Rect(0, 0, 720, 856));
visual.UpdateLayout();
return CollectText(visual);
}
// 대조군 — 환자 없이. 이 줄이 없으면 아래 판정은 "원래 그랬던 것"과 구분되지 않는다.
var withoutPatient = PatientPaper(new StubTagResolver());
Check("환자 태그(대조군): 환자가 없으면 값이 아니라 사유다",
!withoutPatient.Contains("C-9001", StringComparison.Ordinal)
&& withoutPatient.Contains("[PAT_차트번호", StringComparison.Ordinal)
&& withoutPatient.Contains("[PAT_이름", StringComparison.Ordinal),
withoutPatient);
var withPatient = PatientPaper(
new Services.PatientTagResolver(Fake("C-9001", "가상환자"), new StubTagResolver()));
Check("환자 태그: 문맥을 붙이면 값이 찍힌다",
withPatient.Contains("C-9001", StringComparison.Ordinal)
&& withPatient.Contains("가상환자", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_차트번호", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_이름", StringComparison.Ordinal),
withPatient);
// 아직 안 옮긴 환자 태그는 <b>그렇다고 말해야</b> 한다.
// "DB 접속이 없습니다" 로 뭉개면 환자를 골랐는데도 그 사유가 나와 사람이 접속을 의심한다.
Check("환자 태그: 매핑 없는 환자 태그는 안 옮겼다고 말한다",
withPatient.Contains("PAT_나이", StringComparison.Ordinal)
&& withPatient.Contains("아직 옮기지", StringComparison.Ordinal),
withPatient);
// 사슬이 끊기지 않았는가 — 환자를 붙였다고 시각 태그가 죽으면 안 된다
Check("환자 태그: 앞 해석기(시각·사용자)가 계속 산다",
withPatient.Contains("2026-08-18", StringComparison.Ordinal), withPatient);
// 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면
// "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다.
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
// 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다.
var patientPreview = new Views.PreviewWindow(patientVm);
patientPreview.Show();
string PreviewPaper()
{
patientPreview.UpdateLayout();
return CollectText(patientPreview);
}
var previewBefore = PreviewPaper();
patientPreview.UsePatient(Fake("C-9003", "창환자"), "표본");
var previewAfter = PreviewPaper();
Check("미리보기 창: 환자를 붙이면 종이가 값으로 바뀐다",
!previewBefore.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("창환자", StringComparison.Ordinal),
$"붙이기 전 {previewBefore.Contains("C-9003", StringComparison.Ordinal)}"
+ $" / 붙인 뒤 {previewAfter.Contains("C-9003", StringComparison.Ordinal)}");
// 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다.
// 미리보기 창은 하나만 뜨므로 그 잔상은 <b>다음 사람이</b> 보게 된다.
patientPreview.UsePatient(null, string.Empty);
var previewCleared = PreviewPaper();
Check("미리보기 창: 환자를 떼면 값이 사라진다",
!previewCleared.Contains("C-9003", StringComparison.Ordinal)
&& previewCleared.Contains("[PAT_차트번호", StringComparison.Ordinal),
previewCleared);
patientPreview.Close();
var emptyName = PatientPaper(
new Services.PatientTagResolver(Fake("C-9002", string.Empty), new StubTagResolver()));
Check("환자 태그: 값이 비면 사유를 밝힌다",
emptyName.Contains("C-9002", StringComparison.Ordinal)
&& emptyName.Contains("PatNam", StringComparison.Ordinal)
&& emptyName.Contains("비어", StringComparison.Ordinal),
emptyName);
} }
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
@@ -0,0 +1,79 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 환자 문맥으로 태그를 값으로 바꾼다. 못 바꾸면 <b>앞 해석기</b>(시각·로그인 사용자)로 넘긴다.
///
/// <b>매핑을 확인된 것만 넣는다.</b> 레거시 태그 384종 중 238종은 함수 본문에 자기 SQL 을 들고 있고,
/// 나머지도 형식 가공(날짜 포맷·마스킹·결합)이 제각각이다. 컬럼명을 짐작해서 넣으면
/// <b>값이 조용히 틀린다</b> — 빈칸보다 나쁘다. 종이에 그럴듯한 값이 찍히면 아무도 의심하지 않는다.
///
/// 그래서 여기 있는 것은 <b>조회 SQL 에서 컬럼을 직접 확인한</b> 것뿐이다
/// (dtPatVisitList.vb:51-91 · dtCommonLib.vb:3637-3780 의 SELECT 목록).
/// 나머지는 "아직 옮기지 않았다"고 <b>말한다</b> — 환자 문맥이 없어서가 아니라는 사실을 구분해 준다.
///
/// 표를 늘리는 것이 A4 다. 늘릴 때마다 레거시 함수 본문을 읽고 근거를 주석에 남긴다.
/// </summary>
public sealed class PatientTagResolver : ITagValueResolver
{
#region Member Fields
/// <summary>태그 → (문맥 행, 컬럼). 전부 조회 SQL 에서 컬럼을 확인한 것이다.</summary>
private static readonly Dictionary<string, (string Row, string Column)> Direct =
new(StringComparer.Ordinal)
{
// P_PatInf — dtPatVisitList.vb:51 SELECT 목록에서 확인
["PAT_이름"] = ("PatInf", "PatNam"),
["PAT_차트번호"] = ("PatInf", "PatChtNum"),
["PAT_주민번호"] = ("PatInf", "PatResNum"),
["PAT_휴대전화"] = ("PatInf", "PatMblPhn"),
["PAT_전화번호"] = ("PatInf", "PatHomPhn"),
["PAT_주소"] = ("PatInf", "PatAdrDtl"),
};
private readonly ITagValueResolver next;
private readonly PatientContext context;
#endregion
#region Constructors
public PatientTagResolver(PatientContext context, ITagValueResolver next)
{
this.context = context;
this.next = next;
}
#endregion
#region Methods
public TagValue Resolve(string tag)
{
if (!Direct.TryGetValue(tag, out var map))
{
// 환자 문맥이 <b>있는데도</b> 못 만든 것과, 문맥이 없어서 못 만든 것은 다른 일이다.
// 앞 해석기가 시각·사용자 태그를 처리하고, 그것도 아니면 사유를 돌려준다.
var fallback = next.Resolve(tag);
if (fallback.Resolved || TagPreviewCatalog.KindOf(tag) != TagPreviewKind.PatientContext)
{
return fallback;
}
return new TagValue(false, "환자 태그이지만 아직 옮기지 않았습니다");
}
var row = map.Row switch
{
"PatInf" => context.PatInf,
"ComInf" => context.ComInf,
"CodInf" => context.CodInf,
"CoiInf" => context.CoiInf,
_ => context.CowInf,
};
var value = PatientContext.Value(row, map.Column);
return value.Length > 0
? new TagValue(true, value)
: new TagValue(false, $"이 환자에게는 {map.Column} 값이 비어 있습니다");
}
/// <summary>지금 옮겨진 환자 태그 수 — 화면이 커버리지를 정직하게 말할 때 쓴다</summary>
public static int MappedCount => Direct.Count;
#endregion
}
@@ -0,0 +1,146 @@
<Window x:Class="SheetMe.Designer.Views.PatientPickerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="환자 선택 — 미리보기" Width="1040" Height="620"
MinWidth="880" MinHeight="480"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="12">
<!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치,
성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="검색" VerticalAlignment="Center" Margin="0,0,8,0"
Foreground="{DynamicResource B.Muted}"/>
<ComboBox x:Name="KindCombo" Grid.Column="1" Width="110" SelectedIndex="0"
AutomationProperties.Name="검색 유형">
<ComboBoxItem Content="차트번호"/>
<ComboBoxItem Content="성명"/>
<ComboBoxItem Content="주민번호"/>
<ComboBoxItem Content="휴대전화"/>
</ComboBox>
<TextBox x:Name="TermBox" Grid.Column="2" Margin="8,0" VerticalContentAlignment="Center"
KeyDown="OnTermKeyDown" AutomationProperties.Name="검색어"/>
<Button x:Name="SearchButton" Grid.Column="3" Content="찾기(_F)" Padding="18,4" Click="OnSearch"/>
</Grid>
<!-- 감사 고지. 이 창은 실제 환자 정보를 꺼낸다 — 기록된다는 사실을 화면에서 말한다. -->
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"
Text="실제 환자 정보를 불러옵니다. 조회한 사용자와 내원번호가 감사 로그에 남습니다(환자 이름은 남기지 않습니다)."/>
<!-- 아래 줄. 고른 것을 글로 확인시키고 기준 시점을 함께 둔다.
기준 시점은 입원 내원의 진료과·병실·자격 값을 실제로 바꾼다 — 숨기면 값이 왜 다른지 알 수 없다. -->
<Grid DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="ChosenText" Grid.Column="0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource B.Muted}"
Text="환자를 찾아 내원을 고르세요."/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="기준 시점" VerticalAlignment="Center" Margin="0,0,6,0"
Foreground="{DynamicResource B.Muted}"
ToolTip="입원 내원은 구간이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 합니다. 외래는 접수일시로 같습니다."/>
<ComboBox x:Name="EdgeCombo" Width="92" SelectedIndex="0"
AutomationProperties.Name="기준 시점">
<ComboBoxItem Content="마지막"/>
<ComboBoxItem Content="처음"/>
</ComboBox>
<Button x:Name="SelectButton" Content="선택" Padding="20,4" Margin="10,0,0,0"
Click="OnSelect" IsDefault="True" IsEnabled="False"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</Grid>
<!-- 본문. 환자 목록과 그 환자의 내원 목록을 동시에 둔다 —
고르는 대상은 내원이므로 환자를 고른 뒤 화면이 바뀌면 왕복이 생긴다. -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="47*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="53*"/>
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0">
<TextBlock x:Name="PatientCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"
Text="환자 — 검색어를 넣고 찾기를 누르세요"/>
<ListView x:Name="PatientList" SelectionChanged="OnPatientChanged"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
VirtualizingPanel.IsVirtualizing="True"
AutomationProperties.Name="환자 목록">
<ListView.View>
<GridView>
<GridViewColumn Header="차트번호" Width="90" DisplayMemberBinding="{Binding ChtNum}"/>
<GridViewColumn Header="성명" Width="80" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="주민번호" Width="105" DisplayMemberBinding="{Binding ResNumMasked}"/>
<GridViewColumn Header="휴대전화" Width="100" DisplayMemberBinding="{Binding MobilePhone}"/>
<GridViewColumn Header="비고" Width="70">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Inpatient}" Value="True">
<Setter Property="Text" Value="재원"/>
<Setter Property="Foreground" Value="{DynamicResource B.Accent}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Active}" Value="False">
<Setter Property="Text" Value="사용중지"/>
</DataTrigger>
<DataTrigger Binding="{Binding Shielded}" Value="True">
<Setter Property="Text" Value="정보보호"/>
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch"
Background="Transparent"/>
<DockPanel Grid.Column="2">
<TextBlock x:Name="VisitCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"
Text="내원 — 환자를 고르면 나옵니다"/>
<ListView x:Name="VisitList" MouseDoubleClick="OnSelect"
SelectionChanged="OnVisitChanged"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
VirtualizingPanel.IsVirtualizing="True"
AutomationProperties.Name="내원 목록">
<ListView.View>
<GridView>
<GridViewColumn Header="구분" Width="52" DisplayMemberBinding="{Binding Kind}"/>
<GridViewColumn Header="접수일시" Width="118" DisplayMemberBinding="{Binding AcceptedAtText}"/>
<GridViewColumn Header="진료과" Width="100" DisplayMemberBinding="{Binding Department}"/>
<GridViewColumn Header="담당의" Width="80" DisplayMemberBinding="{Binding Doctor}"/>
<GridViewColumn Header="보험" Width="86" DisplayMemberBinding="{Binding Insurance}"/>
<GridViewColumn Header="상태" Width="66" DisplayMemberBinding="{Binding State}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,232 @@
using System.Windows;
using System.Windows.Input;
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Views;
/// <summary>
/// 미리보기에 쓸 <b>실제 환자·내원</b>을 고른다.
///
/// <b>왜 모달인가.</b> 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다.
/// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데,
/// 이 창은 실제 환자 정보를 꺼내는 창이라 <b>열려 있는 것 자체가 비용</b>이다.
///
/// <b>왜 두 목록을 한 화면에 두는가.</b> 고르는 대상은 환자가 아니라 <b>내원</b>이다.
/// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로(<see cref="PatientContextStore"/>)
/// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를
/// 다시 확인하러 왕복해야 한다.
///
/// <b>문맥을 여기서 읽는다.</b> 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고,
/// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다.
/// </summary>
public partial class PatientPickerDialogView : Window
{
#region Member Fields
private readonly PatientVisitStore? store;
private readonly PatientContextStore? contexts;
#endregion
#region Constructors
public PatientPickerDialogView()
{
InitializeComponent();
var connection = ConfigService.Current.ConnectionString;
if (connection.Length > 0)
{
store = new PatientVisitStore(connection);
contexts = new PatientContextStore(connection);
}
Loaded += (_, _) => TermBox.Focus();
}
#endregion
#region Methods
/// <summary>고른 내원의 환자 문맥 — 취소했으면 null</summary>
public PatientContext? Picked { get; private set; }
/// <summary>고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다</summary>
public string PickedLabel { get; private set; } = string.Empty;
/// <summary>
/// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다.
/// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다.
/// </summary>
public static PatientContext? Pick(Window? owner, out string label)
{
label = string.Empty;
if (!FormDesignDataBusiness.CanPreviewPatient())
{
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"실제 환자로 미리보기를 할 수 없습니다.",
FormDesignDataBusiness.PreviewPatientDenyReason(), owner);
return null;
}
var dialog = new PatientPickerDialogView { Owner = owner };
if (dialog.ShowDialog() != true || dialog.Picked is not { } picked)
{
return null;
}
// 감사 기록은 <b>고른 직후</b> 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
// 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다).
AppLog.Audit(PatientPreviewPolicy.AuditLine(
UserSession.Current.UidCod, picked.ComNum.ToString("F0")));
label = dialog.PickedLabel;
return picked;
}
private void OnTermKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
e.Handled = true;
OnSearch(sender, e);
}
}
private void OnSearch(object sender, RoutedEventArgs e)
{
if (store is null)
{
DialogService.Notify(DialogKind.Warning, "환자 검색",
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
return;
}
var kind = KindCombo.SelectedIndex switch
{
1 => PatientSearchKind.Name,
2 => PatientSearchKind.ResidentNumber,
3 => PatientSearchKind.MobilePhone,
_ => PatientSearchKind.ChartNumber,
};
VisitList.ItemsSource = null;
VisitCountText.Text = "내원";
try
{
// 동기로 둔다. 행 상한이 200이라 오래 걸리지 않고, 비동기로 만들면
// "검색 중에 또 검색"을 다뤄야 해서 이 창 크기에 맞지 않는 복잡도가 생긴다.
Mouse.OverrideCursor = Cursors.Wait;
SearchButton.IsEnabled = false;
var found = store.Search(kind, TermBox.Text);
PatientList.ItemsSource = found;
PatientCountText.Text = found.Count switch
{
0 => "환자 — 없습니다",
PatientVisitStore.DefaultLimit =>
$"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요",
_ => $"환자 {found.Count}명",
};
if (found.Count > 0)
{
PatientList.SelectedIndex = 0;
PatientList.Focus();
}
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "환자 검색", "검색 중 오류가 발생했습니다.", ex.Message, this);
}
finally
{
SearchButton.IsEnabled = true;
Mouse.OverrideCursor = null;
}
}
private void OnPatientChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
UpdateChosen();
if (store is null || PatientList.SelectedItem is not PatientSummary patient)
{
return;
}
try
{
Mouse.OverrideCursor = Cursors.Wait;
var visits = store.ListVisits(patient.ChtNum);
VisitList.ItemsSource = visits;
VisitCountText.Text = visits.Count == 0 ? "내원 — 없습니다" : $"내원 {visits.Count}건";
if (visits.Count > 0)
{
VisitList.SelectedIndex = 0;
}
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "내원 조회", "내원을 읽지 못했습니다.", ex.Message, this);
}
finally
{
Mouse.OverrideCursor = null;
}
}
private void OnVisitChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
=> UpdateChosen();
/// <summary>고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다</summary>
private void UpdateChosen()
{
var patient = PatientList.SelectedItem as PatientSummary;
var visit = VisitList.SelectedItem as VisitSummary;
SelectButton.IsEnabled = patient is not null && visit is not null;
ChosenText.Text = (patient, visit) switch
{
(null, _) => "환자를 찾아 내원을 고르세요.",
({ } p, null) => $"{p.Name}({p.ChtNum}) — 내원을 고르세요.",
({ } p, { } v) => $"{p.Name}({p.ChtNum}) · {v.Kind} {v.AcceptedAtText} · 내원 {v.ComNum:F0}",
};
}
private void OnSelect(object sender, RoutedEventArgs e)
{
if (PatientList.SelectedItem is not PatientSummary patient
|| VisitList.SelectedItem is not VisitSummary visit)
{
return;
}
if (contexts is null)
{
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
return;
}
var edge = EdgeCombo.SelectedIndex == 1 ? VisitEdge.First : VisitEdge.Last;
try
{
Mouse.OverrideCursor = Cursors.Wait;
var context = contexts.Load(visit.ComNum, edge);
if (context is null)
{
// 목록에는 있는데 다시 읽을 때 없다 — 그 사이에 지워졌거나 권한이 다르다.
// 창을 닫지 않는다. 닫으면 미리보기가 조용히 빈 종이를 낸다.
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"이 내원의 정보를 읽지 못했습니다.",
$"내원 {visit.ComNum:F0} 을 다시 읽을 수 없습니다. 목록을 새로 검색해 보세요.", this);
return;
}
Picked = context;
PickedLabel = $"{patient.Name}({patient.ChtNum}) · {visit.Kind}"
+ $" {visit.AcceptedAtText} · 기준 {Readable(context.AdpDtm)}";
DialogResult = true;
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "환자 미리보기",
"환자 정보를 읽는 중 오류가 발생했습니다.", ex.Message, this);
}
finally
{
Mouse.OverrideCursor = null;
}
}
private static string Readable(string stamp)
=> stamp.Length == 12
? $"{stamp[..4]}-{stamp[4..6]}-{stamp[6..8]} {stamp[8..10]}:{stamp[10..12]}"
: stamp;
#endregion
}
@@ -21,6 +21,19 @@
Checked="OnViewToggled" Unchecked="OnViewToggled" Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/> ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/>
<Separator/> <Separator/>
<!--
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
-->
<Button x:Name="PatientButton" Content="환자 선택..." Padding="10,3" Click="OnPickPatient"
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient"
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/> <Button Content="" Padding="8,3" Click="OnZoomOut"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/> <TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
<Button Content="" Padding="8,3" Click="OnZoomIn"/> <Button Content="" Padding="8,3" Click="OnZoomIn"/>
@@ -28,10 +28,17 @@ public partial class PreviewWindow : Window
private double zoom = 1.0; private double zoom = 1.0;
/// <summary> /// <summary>
/// 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다. /// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다. /// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
/// </summary> /// </summary>
private readonly SessionTagResolver tags = new(); private readonly SessionTagResolver session = new();
/// <summary>
/// 지금 쓰는 해석기. 환자를 고르면 <see cref="PatientTagResolver"/> 가 앞에 붙고,
/// 해제하면 다시 <see cref="session"/> 뿐이다 — 환자를 해제했는데 옛 값이 남으면
/// 화면이 실제와 다른 것을 보여 준다.
/// </summary>
private SheetMe.Core.Catalog.ITagValueResolver tags;
#endregion #endregion
#region Constructors #region Constructors
@@ -42,6 +49,7 @@ public partial class PreviewWindow : Window
internal PreviewWindow(DesignerViewModel designer) internal PreviewWindow(DesignerViewModel designer)
{ {
this.designer = designer; this.designer = designer;
tags = session;
InitializeComponent(); InitializeComponent();
Activated += (_, _) => BuildPages(); Activated += (_, _) => BuildPages();
Closed += (_, _) => Closed += (_, _) =>
@@ -156,6 +164,40 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다.
///
/// 권한 문·감사 기록·문맥 읽기는 모두 <see cref="PatientPickerDialogView.Pick"/> 안에 있다 —
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
/// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e)
{
var picked = PatientPickerDialogView.Pick(this, out var label);
if (picked is null)
{
return;
}
UsePatient(picked, label);
}
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
private void OnClearPatient(object sender, RoutedEventArgs e) => UsePatient(null, string.Empty);
/// <summary>
/// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
///
/// <b>모달과 분리하는 이유</b>: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
/// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
/// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
/// </summary>
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
tags = picked is null ? session : new PatientTagResolver(picked, session);
PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null;
BuildPages();
}
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary> /// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages(); private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();