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 @@ + + + + + + + + + + + + + + + + + + + + +