From 94bdcc763ff3fc0d8e1ad2fdd6427f87a4542a22 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 15:59:04 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B0=94=EC=9D=B4=ED=83=88=2017=EC=A2=85=20+?= =?UTF-8?q?=20=EC=A3=BC=EC=B9=98=EC=9D=98=201=EC=A2=85=20+=20=5FRefer=20?= =?UTF-8?q?=EC=82=AC=EC=9C=A0=20=EC=A0=95=EC=A0=95=20=E2=80=94=2076?= =?UTF-8?q?=EC=A2=85=20=E2=86=92=2095=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 바이탈(측정치) 17종 E_EmdInf_VITAL ⨝ E_EmrInf(삭제 제외) 를 내원으로 거르고 해당 컬럼이 빈 값이 아닌 행을 측정일시 순으로 한 건 집는다 — 무인자는 첫 측정(ASC), _LAST 는 마지막(DESC). 키/몸무게/체온/맥박/호흡/SPO2 (±LAST) · 혈압/혈압_LAST(BPS||'/'||BPD) · 혈압_BPS/_BPD(단독값이지만 널 필터는 둘 다 — 레거시 그대로) · VITAL접수일시_LAST(HH:MM). 태그별 (식·컬럼·필터·정렬)을 전부 본문에서 확인해 표로 박았다(:11508-13046). 조회는 태그가 물을 때 한 값씩, 태그별 1회 캐시. ## 머리둘레 2종은 레거시가 고장이다 SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽는다(:11744, 12581) — 행이 있으면 예외 → MessageBox → "". 레거시에서 한 번도 값이 나온 적 없는 태그다. 그대로 빈 값 + "레거시 결함(컬럼명 불일치)으로 항상 빈 값이던 태그" 사유로 둔다. 고쳐서 값을 내면 레거시와 달라진다 — 고칠지는 별도 결정. ## 실DB 확인 --db-patient ㉙ 신규: 내원 6004487 에서 첫/마지막 몸무게 2자. 표 부재 가능성도 수술과 같은 방식으로 가른다(ORA-00942 명시). ## 게이트 - dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 95종) - --db-patient ①~㉙ 전건 통과 · --db-render P062 md5 불변 Co-Authored-By: Claude Opus 5 --- src/SheetMe.Data/Stores/VitalStore.cs | 64 ++++++++++++++ src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 27 ++++++ .../Services/PatientIdentity.cs | 9 ++ .../Services/PatientSession.cs | 3 +- .../Services/PatientTagResolver.cs | 83 ++++++++++++++++++- .../Views/PreviewWindow.xaml.cs | 3 +- 6 files changed, 186 insertions(+), 3 deletions(-) create mode 100644 src/SheetMe.Data/Stores/VitalStore.cs diff --git a/src/SheetMe.Data/Stores/VitalStore.cs b/src/SheetMe.Data/Stores/VitalStore.cs new file mode 100644 index 0000000..ba08924 --- /dev/null +++ b/src/SheetMe.Data/Stores/VitalStore.cs @@ -0,0 +1,64 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// 바이탈(측정치) 조회 — 레거시 OCM_키/몸무게/체온/혈압/맥박/호흡/SPO2(±_LAST) 계열 +/// (bzDataInterface.vb:11508-13046)의 공통 조회. +/// +/// 전부 같은 틀이다: +/// E_EmdInf_VITAL ⨝ E_EmrInf(EmrKey=EmdEmrKey, 삭제 제외) 를 내원으로 거르고 +/// 해당 컬럼이 비어 있지 않은 행을 측정일시 순으로 한 건 집는다 — +/// 무인자는 첫 측정(ASC), _LAST 는 마지막 측정(DESC). +/// +/// SELECT 식·널 필터·정렬까지 태그마다 본문에서 확인한 대로 호출부(해석기)가 지정한다. +/// 식과 컬럼명은 전부 코드 상수에서만 온다 — 사용자 입력이 닿는 경로가 아니다. +/// +public sealed class VitalStore +{ + #region Member Fields + private readonly string connectionString; + #endregion + + #region Constructors + public VitalStore(string connectionString) => this.connectionString = connectionString; + #endregion + + #region Methods + /// 측정치 한 건 — 없으면 빈 문자열 + /// SELECT 식(별칭 포함) — 예: "EmdWeight" / "EmdBps || '/' || EmdBpd AS BP" + /// 결과에서 집을 컬럼(별칭) + /// 비어 있으면 안 되는 컬럼들(레거시 필터 그대로) + /// 정렬 절 — 예: "EmdDte ASC, EmdTime ASC" + public string One(decimal comNum, string selectExpr, string pick, + string[] notNullColumns, string orderBy) + { + var filters = string.Concat(notNullColumns.Select(c => + $" AND ({c} IS NOT NULL AND {c} <> ' ') ")); + var sql = + "SELECT * FROM (" + + $" SELECT {selectExpr}" + + " FROM E_EmdInf_VITAL" + + " INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey" + + " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D')" + + " WHERE EmrComNum = :iComNum" + + filters + + $" ORDER BY {orderBy}" + + " ) WHERE ROWNUM = 1"; + + using var connection = new OracleConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = sql; + command.Parameters.Add(new OracleParameter("iComNum", comNum)); + using var reader = command.ExecuteReader(); + if (!reader.Read()) + { + return string.Empty; + } + var at = reader.GetOrdinal(pick); + return reader.IsDBNull(at) ? string.Empty : reader.GetValue(at).ToString()!.Trim(); + } + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index c14d341..36f757f 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2264,6 +2264,33 @@ public static class DbSmoke Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0, "OprStt='E' 조건이나 조인을 의심할 것"); } + + // ㉙ 바이탈 — E_EmdInf_VITAL 에 행을 가진 내원으로 실행 확인 + var vitOwner = FirstComNum(conn, + "E_EmdInf_VITAL INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey", + "EmrComNum", "EmrComNum"); + if (vitOwner is null) + { + foreach (var line in ExplainPeriod(conn, 0, + "SELECT COUNT(*) VITCNT FROM E_EmdInf_VITAL WHERE :k = 0")) + { + lines.Add($" [진단] E_EmdInf_VITAL: {line}"); + } + lines.Add("SKIP ㉙ 바이탈 — 확인 불가. 위 [진단] 줄이 원인이다" + + " (ORA-00942 면 이 시험 DB 에 표가 없어 운영 DB 에서만 검증 가능)"); + } + else + { + var vital = new VitalStore(conn); + var weight = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight", + new[] { "EmdWeight" }, "EmdDte ASC, EmdTime ASC"); + var weightLast = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight", + new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC"); + lines.Add($" 바이탈: 내원 {vitOwner.Value:F0} → 첫 몸무게 {weight.Length}자," + + $" 마지막 {weightLast.Length}자 (값은 기록하지 않는다)"); + Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0, + "EmdWeight 널 필터나 조인을 의심할 것"); + } } } } diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index 635a80a..949a011 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -152,6 +152,15 @@ public static class PatientIdentity : new ReviewDiagnosisStore(connection).Diagnoses(context.ComNum, scope); } + /// 바이탈 공급자 — 태그가 물을 때 한 값씩 읽는다(측정 태그 없는 서식이 다수다) + public static Func VitalSourceFor(PatientContext context) + { + var connection = ConfigService.Current.ConnectionString; + return (expr, pick, notNull, order) => connection.Length == 0 + ? string.Empty + : new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order); + } + /// 수술 행 공급자 — 상병과 같은 정책(필요할 때 1회, 실패는 던져 사유로) public static Func>> SurgerySourceFor(PatientContext context) { diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index 1b43ad5..11e6379 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -71,7 +71,8 @@ public static class PatientSession PatientIdentity.HospitalRowOf(picked), PatientIdentity.DiagnosisSourceFor(picked), PatientIdentity.SurgerySourceFor(picked), - PatientIdentity.ReviewDiagnosisSourceFor(picked)) + PatientIdentity.ReviewDiagnosisSourceFor(picked), + PatientIdentity.VitalSourceFor(picked)) : session; return (tags, new MDataTableRunner(document, Variables())); } diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index 3c506ee..8804abb 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -185,6 +185,11 @@ public sealed class PatientTagResolver : ITagValueResolver /// 심사 상병(B_OkdInf) 공급자 — 상병과 같은 정책 private readonly Func>>? reviewSource; + /// 바이탈 공급자 — (식, 컬럼, 필터, 정렬) 로 한 값을 읽는다. 태그별 1회 캐시 + private readonly Func? vitalSource; + + private readonly Dictionary vitalCache = new(StringComparer.Ordinal); + private readonly Dictionary>> reviewCache = new(); public PatientTagResolver(PatientContext context, ITagValueResolver next, @@ -195,7 +200,8 @@ public sealed class PatientTagResolver : ITagValueResolver IReadOnlyDictionary? hospital = null, Func>>? diagnosisSource = null, Func>>? surgerySource = null, - Func>>? reviewSource = null) + Func>>? reviewSource = null, + Func? vitalSource = null) { this.context = context; this.next = next; @@ -208,6 +214,7 @@ public sealed class PatientTagResolver : ITagValueResolver this.diagnosisSource = diagnosisSource; this.surgerySource = surgerySource; this.reviewSource = reviewSource; + this.vitalSource = vitalSource; } #endregion @@ -243,6 +250,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return reviewValue; } + if (FromVital(tag) is { } vitalValue) + { + return vitalValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -703,6 +714,72 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "심사 상병 행은 있는데 그 값이 비어 있습니다"); } + /// + /// 바이탈 17종 — 태그별 (SELECT 식, 집는 컬럼, 널 필터, 정렬). + /// 전부 본문에서 확인(bzDataInterface.vb:11508-13046). 무인자 = 첫 측정(ASC), _LAST = 마지막(DESC). + /// 혈압 계열은 BPS·BPD 둘 다 비어 있지 않아야 한다(레거시 필터 그대로 — _BPS 단독도 둘 다 건다). + /// + private static readonly Dictionary Vitals = + new(StringComparer.Ordinal) + { + ["OCM_키"] = ("EmdHeight", "EmdHeight", new[] { "EmdHeight" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_키_LAST"] = ("EmdHeight", "EmdHeight", new[] { "EmdHeight" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_몸무게"] = ("EmdWeight", "EmdWeight", new[] { "EmdWeight" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_몸무게_LAST"] = ("EmdWeight", "EmdWeight", new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_체온"] = ("EmdTemp", "EmdTemp", new[] { "EmdTemp" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_체온_LAST"] = ("EmdTemp", "EmdTemp", new[] { "EmdTemp" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_맥박"] = ("EmdPuls", "EmdPuls", new[] { "EmdPuls" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_맥박_LAST"] = ("EmdPuls", "EmdPuls", new[] { "EmdPuls" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_호흡"] = ("EmdResp", "EmdResp", new[] { "EmdResp" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_호흡_LAST"] = ("EmdResp", "EmdResp", new[] { "EmdResp" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_SPO2"] = ("EmdSpo", "EmdSpo", new[] { "EmdSpo" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_SPO2_LAST"] = ("EmdSpo", "EmdSpo", new[] { "EmdSpo" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_혈압"] = ("EmdBps || '/' || EmdBpd AS BP", "BP", + new[] { "EmdBps", "EmdBpd" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_혈압_LAST"] = ("EmdBps || '/' || EmdBpd AS BP", "BP", + new[] { "EmdBps", "EmdBpd" }, "EmdDte DESC, EmdTime DESC"), + ["OCM_혈압_BPS"] = ("EmdBps AS BP", "BP", new[] { "EmdBps", "EmdBpd" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_혈압_BPD"] = ("EmdBpd AS BP", "BP", new[] { "EmdBps", "EmdBpd" }, "EmdDte ASC, EmdTime ASC"), + ["OCM_VITAL접수일시_LAST"] = ( + "SUBSTR(EmrAdpTim,1,2) || ':' || SUBSTR(EmrAdpTim,3,2) AS EmrAdpTim", "EmrAdpTim", + new[] { "EmrAdpTim" }, "EmrAdpTim DESC"), + }; + + private TagValue? FromVital(string tag) + { + // 머리둘레 두 종은 레거시 결함이다 — SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽어 + // 행이 있으면 예외 → MessageBox → "" 다(bzDataInterface.vb:11744, 12581). + // 즉 레거시에서 한 번도 값이 나온 적 없는 태그다. 그대로 빈 값 + 사유로 둔다. + if (tag is "OCM_머리둘레" or "OCM_머리둘레_LAST") + { + return new TagValue(false, + "레거시 결함(컬럼명 불일치)으로 항상 빈 값이던 태그입니다"); + } + if (!Vitals.TryGetValue(tag, out var spec)) + { + return null; + } + if (vitalSource is null) + { + return new TagValue(false, "바이탈 조회가 연결되지 않았습니다"); + } + if (!vitalCache.TryGetValue(tag, out var value)) + { + try + { + value = vitalSource(spec.Expr, spec.Pick, spec.NotNull, spec.Order); + } + catch (Exception ex) + { + return new TagValue(false, $"바이탈 조회에 실패했습니다: {ex.GetType().Name}"); + } + vitalCache[tag] = value; + } + return value.Length > 0 + ? new TagValue(true, value) + : new TagValue(false, "이 내원에 해당 측정 기록이 없습니다"); + } + /// 주민번호에서 나온 값 — 비면 비었는지 말한다 private TagValue Wrap(string value) => value.Length > 0 ? new TagValue(true, value) @@ -732,6 +809,10 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_OPNAME", "OCM_OPRCODNAM", "OCM_DX", "OCM_수술상병", "OCM_수술부위", "OCM_수술_OprPatETC", "OCM_수술집도의", "OCM_수술마취의사", "OCM_심사_주상병명", "OCM_심사_주상병코드", "OCM_심사_부상병명", "OCM_심사_부상병코드", + "OCM_키", "OCM_키_LAST", "OCM_몸무게", "OCM_몸무게_LAST", "OCM_체온", "OCM_체온_LAST", + "OCM_맥박", "OCM_맥박_LAST", "OCM_호흡", "OCM_호흡_LAST", "OCM_SPO2", "OCM_SPO2_LAST", + "OCM_혈압", "OCM_혈압_LAST", "OCM_혈압_BPS", "OCM_혈압_BPD", "OCM_VITAL접수일시_LAST", + "OCM_머리둘레", "OCM_머리둘레_LAST", }; /// diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index a569bb1..ecd8a36 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -228,7 +228,8 @@ public partial class PreviewWindow : Window PatientIdentity.HospitalRowOf(picked), PatientIdentity.DiagnosisSourceFor(picked), PatientIdentity.SurgerySourceFor(picked), - PatientIdentity.ReviewDiagnosisSourceFor(picked)); + PatientIdentity.ReviewDiagnosisSourceFor(picked), + PatientIdentity.VitalSourceFor(picked)); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document,