diff --git a/src/SheetMe.Data/Stores/PatientContextStore.cs b/src/SheetMe.Data/Stores/PatientContextStore.cs
new file mode 100644
index 0000000..58fcb89
--- /dev/null
+++ b/src/SheetMe.Data/Stores/PatientContextStore.cs
@@ -0,0 +1,128 @@
+using Oracle.ManagedDataAccess.Client;
+
+namespace SheetMe.Data.Stores;
+
+///
+/// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다.
+/// 각 행은 컬럼명 → 값 사전이다. 태그가 <<Patient.PatInfDR.item("PatNam")>> 처럼
+/// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다.
+///
+public sealed record PatientContext(
+ decimal ComNum,
+ string AdpDtm,
+ IReadOnlyDictionary PatInf,
+ IReadOnlyDictionary ComInf,
+ IReadOnlyDictionary CodInf,
+ IReadOnlyDictionary CoiInf,
+ IReadOnlyDictionary CowInf)
+{
+ /// 행 하나에서 컬럼 값 — 없으면 빈 문자열
+ public static string Value(IReadOnlyDictionary row, string column)
+ => row.TryGetValue(column, out var value) ? value : string.Empty;
+}
+
+///
+/// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 bzPatientInfo.SetPatientInfo 대응.
+///
+/// 다섯 행이 전부 적용일시를 키로 쓴다. 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf),
+/// 병동·병실(P_CowInf) 은 :AdpDtm BETWEEN StrDtm AND EndDtm 이다 —
+/// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다.
+/// 그래서 시각 판정은 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다.
+///
+/// 레거시와 다른 점: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다
+/// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 한 번에 다섯 번 읽고 들고 있는다 —
+/// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.
+///
+/// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다.
+///
+public sealed class PatientContextStore
+{
+ #region Member Fields
+ private readonly string connectionString;
+ #endregion
+
+ #region Constructors
+ public PatientContextStore(string connectionString) => this.connectionString = connectionString;
+ #endregion
+
+ #region Methods
+ /// 이 내원의 문맥 — 내원 자체가 없으면 null
+ 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);
+ }
+
+ ///
+ /// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
+ /// 컬럼명은 대문자로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
+ /// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다.
+ ///
+ private static Dictionary Row(OracleConnection connection, string sql,
+ params (string Name, object Value)[] parameters)
+ {
+ var result = new Dictionary(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
+}
diff --git a/src/SheetMe.Data/Stores/PatientVisitStore.cs b/src/SheetMe.Data/Stores/PatientVisitStore.cs
index f69da8f..1422989 100644
--- a/src/SheetMe.Data/Stores/PatientVisitStore.cs
+++ b/src/SheetMe.Data/Stores/PatientVisitStore.cs
@@ -26,7 +26,26 @@ public sealed record PatientSummary(
/// 내원 1건
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)
+{
+ /// 접수일시 읽기 좋게 — 목록에서 12자리 숫자를 사람이 읽으려면 자리를 세어야 한다
+ public string AcceptedAtText => Readable(AcceptedAt);
+
+ /// 퇴원일시 읽기 좋게. 재원 중이면 빈칸이다
+ public string LeftAtText => Readable(LeftAt);
+
+ ///
+ /// YYYYMMDDHHMM → "2026-08-18 09:30". 길이가 다르면 손대지 않고 그대로 보여 준다 —
+ /// 잘라서 그럴듯하게 만들면 데이터가 이상한 것을 화면이 감춘다.
+ ///
+ 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;
+ }
+}
///
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
index f97a245..f52ddc4 100644
--- a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
+++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
@@ -131,6 +131,20 @@ internal sealed class FormDesignDataBusiness
public static string ModifyDenyReason() => ModifyPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
+ ///
+ /// 이 사용자가 실제 환자로 미리보기를 할 수 있는가.
+ ///
+ /// 수정과 같은 문을 쓴다 — 서식생성기에서 환자 정보를 꺼내 보는 것은 진료 행위가 아니라
+ /// 서식이 맞게 뽑히는지 확인하는 일이고, 그 권한은 레거시가 전산실에만 열어 둔 것과 같은 범위다.
+ /// 여기를 넓히면 서식생성기가 환자 조회 도구가 된다.
+ ///
+ 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);
+
///
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 안 걸리고 부서 규칙(EDPS)으로 떨어진다.
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index a0f39f5..adaecdc 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -13,6 +13,18 @@ namespace SheetMe.Designer.Diagnostics;
///
public static class DbSmoke
{
+ #region Nested Types
+ ///
+ /// 아무것도 해석하지 않는 사슬 꼬리 — 환자 해석기만 판정하려면 뒤가 조용해야 한다.
+ /// 세션 해석기를 꼬리로 쓰면 시각 태그가 값이 되어 "환자 해석기가 됐다"와 섞인다.
+ ///
+ private sealed class NullTagResolver : SheetMe.Core.Catalog.ITagValueResolver
+ {
+ public SheetMe.Core.Catalog.TagValue Resolve(string tag)
+ => new(false, "해석기 없음");
+ }
+ #endregion
+
#region Methods
/// 읽기 전용 일괄 왕복 스모크
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);
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
- // 내원 목록 — 검색으로 찾은 첫 환자로
- 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();
+ foreach (var candidate in people)
+ {
+ probed++;
+ visits = store.ListVisits(candidate.ChtNum);
+ if (visits.Count > 0)
+ {
+ break;
+ }
+ }
if (people.Count == 0)
{
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
@@ -1883,13 +1908,133 @@ public static class DbSmoke
}
else
{
- var visits = store.ListVisits(people[0].ChtNum);
+ lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)");
lines.Add($" 내원 {visits.Count}건");
Check("⑥ 내원 목록이 예외 없이 돈다", true);
// 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다
Check("⑦ 같은 내원이 중복되지 않는다",
visits.Select(v => v.ComNum).Distinct().Count() == visits.Count,
$"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}");
+
+ // ── 환자 문맥 ──
+ // 여기가 이 조사의 목적이다. 위 ①~⑦ 은 목록이 뜨는지만 본다.
+ // 문맥 조회의 컬럼 이름은 추측으로 쓴 것이라 실DB 아니면 확인할 방법이 없고,
+ // 틀리면 예외가 아니라 빈 값이 나온다 — 화면에서는 "환자가 값이 없다"로 보인다.
+ if (visits.Count == 0)
+ {
+ // 여기까지 오면 훑은 환자 전부가 내원 0건이다. 검색어를 바꿔 다시 돌려야 한다 —
+ // 이 줄을 통과로 세면 문맥 조회를 한 번도 확인하지 않은 채 초록불이 된다.
+ 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을 그냥 적지 않고 이유를 캔다. 조건을 뺀 원본 행을 함께 찍으면
+ // "그 내원에 애초에 행이 없다"와 "행은 있는데 시각·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 조건을 판단할 것");
+
+ // ── 기간 조회 세 갈래를 실제로 한 번은 통과시킨다 ──
+ // 위 내원은 CodInf·CoiInf·CowInf 가 전부 0행이었다. 즉 이 세 SQL 의
+ // BETWEEN·MtiSeq 조건은 아직 한 번도 행을 돌려준 적이 없다.
+ // 안 돌아본 갈래는 통과한 갈래가 아니다 — 그래서 각 표에서 직접 내원을 찾아 들어간다.
+ 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);
@@ -1903,6 +2048,67 @@ public static class DbSmoke
}
}
+ ///
+ /// 이 표에 행을 가진 내원 하나 — 기간 조회 갈래를 실제로 통과시켜 보려면 재료가 필요하다.
+ /// 최근 것을 고른다(오래된 내원은 구간이 이미 닫혀 있어 판정이 덜 대표적이다).
+ /// 표 이름·컬럼 이름은 호출부의 리터럴만 들어온다 — 사용자 입력이 닿는 경로가 아니다.
+ ///
+ 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;
+ }
+ }
+
+ ///
+ /// 기간 조회가 0행일 때 왜 0행인지 보여 준다 — 조건을 뺀 원본 행의 구간을 그대로 찍는다.
+ /// 값이 아니라 구간만 읽으므로 개인정보를 꺼내지 않는다.
+ ///
+ private static List ExplainPeriod(string connectionString, decimal comNum, string sql)
+ {
+ var rows = new List();
+ 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)
{
var lines = new List();
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index 0ff70f4..d8675bf 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -149,6 +149,9 @@ public static class DialogShots
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)),
+ // 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
+ // 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
+ ("08b-patient-picker", () => new Views.PatientPickerDialogView()),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")),
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 662e2ca..c25a23d 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -453,6 +453,126 @@ public static class EditSmoke
&& withResolver.Contains("환자", StringComparison.Ordinal)
&& withResolver.Contains("[", StringComparison.Ordinal),
withResolver);
+
+ // ── 환자 문맥을 붙였을 때 ──
+ // 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고,
+ // 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다").
+ // DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다.
+ static SheetMe.Data.Stores.PatientContext Fake(string chart, string name)
+ {
+ var patInf = new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["PATCHTNUM"] = chart,
+ ["PATNAM"] = name,
+ };
+ var blank = new Dictionary(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);
+
+ // 아직 안 옮긴 환자 태그는 그렇다고 말해야 한다.
+ // "DB 접속이 없습니다" 로 뭉개면 환자를 골랐는데도 그 사유가 나와 사람이 접속을 의심한다.
+ Check("환자 태그: 매핑 없는 환자 태그는 안 옮겼다고 말한다",
+ withPatient.Contains("PAT_나이", StringComparison.Ordinal)
+ && withPatient.Contains("아직 옮기지", StringComparison.Ordinal),
+ withPatient);
+
+ // 사슬이 끊기지 않았는가 — 환자를 붙였다고 시각 태그가 죽으면 안 된다
+ Check("환자 태그: 앞 해석기(시각·사용자)가 계속 산다",
+ withPatient.Contains("2026-08-18", StringComparison.Ordinal), withPatient);
+
+ // 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면
+ // "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다.
+ // ── 미리보기 창을 통해서 값이 흐르는가 ──
+ // 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
+ // 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
+ // 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다.
+ 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)}");
+
+ // 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다.
+ // 미리보기 창은 하나만 뜨므로 그 잔상은 다음 사람이 보게 된다.
+ 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) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
new file mode 100644
index 0000000..f4adb6e
--- /dev/null
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -0,0 +1,79 @@
+using SheetMe.Core.Catalog;
+using SheetMe.Data.Stores;
+
+namespace SheetMe.Designer.Services;
+
+///
+/// 환자 문맥으로 태그를 값으로 바꾼다. 못 바꾸면 앞 해석기(시각·로그인 사용자)로 넘긴다.
+///
+/// 매핑을 확인된 것만 넣는다. 레거시 태그 384종 중 238종은 함수 본문에 자기 SQL 을 들고 있고,
+/// 나머지도 형식 가공(날짜 포맷·마스킹·결합)이 제각각이다. 컬럼명을 짐작해서 넣으면
+/// 값이 조용히 틀린다 — 빈칸보다 나쁘다. 종이에 그럴듯한 값이 찍히면 아무도 의심하지 않는다.
+///
+/// 그래서 여기 있는 것은 조회 SQL 에서 컬럼을 직접 확인한 것뿐이다
+/// (dtPatVisitList.vb:51-91 · dtCommonLib.vb:3637-3780 의 SELECT 목록).
+/// 나머지는 "아직 옮기지 않았다"고 말한다 — 환자 문맥이 없어서가 아니라는 사실을 구분해 준다.
+///
+/// 표를 늘리는 것이 A4 다. 늘릴 때마다 레거시 함수 본문을 읽고 근거를 주석에 남긴다.
+///
+public sealed class PatientTagResolver : ITagValueResolver
+{
+ #region Member Fields
+ /// 태그 → (문맥 행, 컬럼). 전부 조회 SQL 에서 컬럼을 확인한 것이다.
+ private static readonly Dictionary 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))
+ {
+ // 환자 문맥이 있는데도 못 만든 것과, 문맥이 없어서 못 만든 것은 다른 일이다.
+ // 앞 해석기가 시각·사용자 태그를 처리하고, 그것도 아니면 사유를 돌려준다.
+ 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} 값이 비어 있습니다");
+ }
+
+ /// 지금 옮겨진 환자 태그 수 — 화면이 커버리지를 정직하게 말할 때 쓴다
+ public static int MappedCount => Direct.Count;
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml b/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml
new file mode 100644
index 0000000..0e4dc54
--- /dev/null
+++ b/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml
@@ -0,0 +1,146 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml.cs b/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml.cs
new file mode 100644
index 0000000..6caf0d9
--- /dev/null
+++ b/src/SheetMe.Designer/Views/PatientPickerDialogView.xaml.cs
@@ -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;
+
+///
+/// 미리보기에 쓸 실제 환자·내원을 고른다.
+///
+/// 왜 모달인가. 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다.
+/// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데,
+/// 이 창은 실제 환자 정보를 꺼내는 창이라 열려 있는 것 자체가 비용이다.
+///
+/// 왜 두 목록을 한 화면에 두는가. 고르는 대상은 환자가 아니라 내원이다.
+/// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로()
+/// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를
+/// 다시 확인하러 왕복해야 한다.
+///
+/// 문맥을 여기서 읽는다. 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고,
+/// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다.
+///
+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
+ /// 고른 내원의 환자 문맥 — 취소했으면 null
+ public PatientContext? Picked { get; private set; }
+
+ /// 고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다
+ public string PickedLabel { get; private set; } = string.Empty;
+
+ ///
+ /// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다.
+ /// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다.
+ ///
+ 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;
+ }
+ // 감사 기록은 고른 직후 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
+ // 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다).
+ 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();
+
+ /// 고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다
+ 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
+}
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml b/src/SheetMe.Designer/Views/PreviewWindow.xaml
index 0a0d2c4..4663819 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml
@@ -21,6 +21,19 @@
Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/>
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
index 2d6a92c..67f2102 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
@@ -28,10 +28,17 @@ public partial class PreviewWindow : Window
private double zoom = 1.0;
///
- /// 태그 값 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
+ /// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
///
- private readonly SessionTagResolver tags = new();
+ private readonly SessionTagResolver session = new();
+
+ ///
+ /// 지금 쓰는 해석기. 환자를 고르면 가 앞에 붙고,
+ /// 해제하면 다시 뿐이다 — 환자를 해제했는데 옛 값이 남으면
+ /// 화면이 실제와 다른 것을 보여 준다.
+ ///
+ private SheetMe.Core.Catalog.ITagValueResolver tags;
#endregion
#region Constructors
@@ -42,6 +49,7 @@ public partial class PreviewWindow : Window
internal PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
+ tags = session;
InitializeComponent();
Activated += (_, _) => BuildPages();
Closed += (_, _) =>
@@ -156,6 +164,40 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
+ ///
+ /// 실제 환자를 골라 태그에 값을 넣는다.
+ ///
+ /// 권한 문·감사 기록·문맥 읽기는 모두 안에 있다 —
+ /// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
+ ///
+ private void OnPickPatient(object sender, RoutedEventArgs e)
+ {
+ var picked = PatientPickerDialogView.Pick(this, out var label);
+ if (picked is null)
+ {
+ return;
+ }
+ UsePatient(picked, label);
+ }
+
+ /// 환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다
+ private void OnClearPatient(object sender, RoutedEventArgs e) => UsePatient(null, string.Empty);
+
+ ///
+ /// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
+ ///
+ /// 모달과 분리하는 이유: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
+ /// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
+ /// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
+ ///
+ 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();
+ }
+
/// 보기 전환 — 인쇄 기준 / 화면 그대로
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();