diff --git a/src/SheetMe.Data/Stores/DiagnosisStore.cs b/src/SheetMe.Data/Stores/DiagnosisStore.cs new file mode 100644 index 0000000..f61dede --- /dev/null +++ b/src/SheetMe.Data/Stores/DiagnosisStore.cs @@ -0,0 +1,128 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// 상병(진단) 조회 — 레거시 Okd_상병쿼리(bzDataInterface.vb:14160-14623)의 이식. +/// +/// 이 DB(SRCH)가 타는 Case Else 갈래만 옮겼다. 원문은 병원 코드로 여섯 갈래 +/// (KIMEYE·NYJB·SYBS·HIMCHAN_CW·SPHH·Else)로 갈리는데, 그 다섯은 SELECT 목록·이름 결합이 +/// 제각각이라 각각 별도 이식이다. 그 병원에서는 태그가 미이식이라고 말한다 — +/// Else 갈래로 얼버무리면 그 병원 서식에 다른 모양의 상병명이 조용히 찍힌다. +/// +/// SQL 은 원문에서 기계 추출했다(Select Case 를 Case Else 경로로 시뮬레이션) — +/// 손으로 다시 치면 공백·결합 규칙이 달라진다. 구조는: +/// 블록1(M_KcdMst 와 맞는 상병) UNION 블록2(마스터에 없는 코드 — LEFT JOIN 후 KcdCod IS NULL) +/// 각 블록에 주/부 필터가 붙고 블록마다 ROW_NUMBER=1 로 접은 뒤 OkdDspSeq 순으로 정렬한다. +/// +/// 값은 이 저장소가 만들지 않는다 — 행 목록만 돌려주고 결합(가로/세로·확진/의증 접미)은 +/// 해석기가 한다. 태그 8종이 같은 결과셋을 다르게 자를 뿐이기 때문이다. +/// +public sealed class DiagnosisStore +{ + #region Member Fields + /// 주/부 구분 — 레거시 psOkdTyp(T=전체, M=주상병, S=부상병) + public enum Scope { All, Main, Sub } + + private readonly string connectionString; + + /// 원문 그대로의 쿼리 틀 — {TYPE} 자리에 주/부 필터가 들어간다 + private const string Template = @" +SELECT * +FROM ( + SELECT * + FROM ( + SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod + , KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdElcCod || '-' || TRIM(KcdSeq) AS KcdSeqCod, OkdPoaTyp + , OkdDtrNam + , ROW_NUMBER() OVER(PARTITION BY KcdCod ORDER BY KcdStrDte DESC) AS OKD_RN + FROM O_OkdInf + INNER JOIN P_ComInf + ON OkdComNum = ComNum + INNER JOIN M_KcdMst + ON KcdCod = TRIM(OkdKcdCod) + AND TRIM(OkdKcdSeq) = KcdSeq + AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8) + AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8) + WHERE OkdComNum = :piComNum + AND OkdStt = 'E' +{TYPE} + ) OKD + WHERE OKD_RN = 1 + + UNION + + SELECT * + FROM ( + SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod + , KCD.KcdCod, KCD.KcdElcCod, KCD.KcdKorNam, KCD.KcdEngNam, KCD.KcdElcCod || '-' || TRIM(KCD.KcdSeq) AS KcdSeqCod, OkdPoaTyp + , OkdDtrNam + , ROW_NUMBER() OVER(PARTITION BY KCD.KcdCod ORDER BY KCD.KcdStrDte DESC) AS OKD_RN + FROM ( + SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod + , KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdSeq, OkdPoaTyp + , OkdDtrNam + FROM O_OkdInf + INNER JOIN P_ComInf + ON OkdComNum = ComNum + LEFT JOIN M_KcdMst + ON KcdCod = TRIM(OkdKcdCod) + AND TRIM(OkdKcdSeq) = KcdSeq + AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8) + AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8) + WHERE OkdComNum = :piComNum + AND OkdStt = 'E' + ) OKD + INNER JOIN M_KcdMst KCD + ON KCD.KcdCod = TRIM(OkdKcdCod) + AND OkdKcdSeq = KCD.KcdSeq + WHERE 1=1 + AND (OKD.KcdCod IS NULL OR OKD.KcdCod = ' ') +{TYPE} + ) OKD + WHERE OKD_RN = 1 + ) +ORDER BY OkdDspSeq +"; + #endregion + + #region Constructors + public DiagnosisStore(string connectionString) => this.connectionString = connectionString; + #endregion + + #region Methods + /// 이 내원의 상병 행들 — 표시 순서(OkdDspSeq)대로. 없으면 빈 목록 + public List> Diagnoses(decimal comNum, Scope scope = Scope.All) + { + var filter = scope switch + { + Scope.Main => " AND OkdDspSeq = '1' ", + Scope.Sub => " AND OkdDspSeq <> '1' ", + _ => string.Empty, + }; + var sql = Template.Replace("{TYPE}", filter); + + using var connection = new OracleConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = sql; + command.Parameters.Add(new OracleParameter("piComNum", comNum)); + + var rows = new List>(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var row = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (var i = 0; i < reader.FieldCount; i++) + { + row[reader.GetName(i)] = reader.IsDBNull(i) + ? string.Empty + : reader.GetValue(i).ToString()!.Trim(); + } + rows.Add(row); + } + return rows; + } + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 4c6d5cb..b889bc3 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2204,6 +2204,39 @@ public static class DbSmoke lines.Add($" 요양기관명: {hosp.Length}자"); Check("㉔ 요양기관명이 나온다", hosp.Trim().Length > 0, "M_DepMst⨝M_HspMst 조인이 0행 — 유효기간이나 DepHspCod 를 의심할 것"); + + // ㉕ 상병 — 원문 기계 추출 SQL 이 실제로 돈다. + // 이 내원에 상병이 없을 수 있으므로 O_OkdInf 에 행을 가진 내원을 찾아 건다 + // (앞서 두 번 밟은 "행 없는 내원으로 판정" 함정). + var okdOwner = FirstComNum(conn, "O_OkdInf", "OkdComNum", "OkdComNum"); + if (okdOwner is null) + { + Check("㉕ 상병 — O_OkdInf 에 행이 없어 확인 불가", false, + "이 DB 로는 이 갈래를 확인할 수 없다"); + } + else + { + var diag = new DiagnosisStore(conn); + var all = diag.Diagnoses(okdOwner.Value); + var main = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Main); + var sub = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Sub); + lines.Add($" 상병: 내원 {okdOwner.Value:F0} → 전체 {all.Count}행," + + $" 주 {main.Count}행, 부 {sub.Count}행"); + Check("㉕ 상병 쿼리(전체)가 행을 돌려준다", all.Count > 0, + "OkdStt='E' 조건이나 UNION 결합을 의심할 것"); + Check("㉖ 주/부 필터가 실제로 가른다", main.Count + sub.Count == all.Count + && main.Count <= all.Count, + $"주 {main.Count} + 부 {sub.Count} ≠ 전체 {all.Count} — RN 접기 전 필터 규칙 확인"); + if (all.Count > 0) + { + var okdKorNam = all[0].TryGetValue("KcdKorNam", out var kn) ? kn : ""; + lines.Add($" 첫 상병: 한글명 {okdKorNam.Length}자," + + $" 코드 {(all[0].TryGetValue("KcdElcCod", out var kc) ? kc.Length : 0)}자" + + " (값은 기록하지 않는다)"); + Check("㉗ 상병 한글명이 나온다", okdKorNam.Trim().Length > 0, + "M_KcdMst 조인이 빈 이름을 냈다"); + } + } } } } diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index b1994f4..11f9c88 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -94,6 +94,21 @@ public static class PatientIdentity } } + /// + /// 상병 행 공급자 — 해석기가 필요할 때 갈래(전체/주/부)별로 부른다. + /// 여기서 미리 읽지 않는 이유는 상병 태그가 없는 서식이 다수이기 때문이다. + /// 실패는 던진다 — 해석기가 잡아 사유로 바꾼다(조용히 빈 목록을 주면 + /// "상병이 없다"와 "조회가 죽었다"를 화면에서 못 가른다). + /// + public static Func>> + DiagnosisSourceFor(PatientContext context) + { + var connection = ConfigService.Current.ConnectionString; + return scope => connection.Length == 0 + ? new List>() + : new DiagnosisStore(connection).Diagnoses(context.ComNum, scope); + } + /// /// 이 내원의 진료과 마스터 행 — 못 읽으면 빈 사전. /// 진료과 코드는 문맥의 CodInf(적용일시로 고른 행)에서, 유효기간 일자는 diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index e8a3692..0291ae7 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -67,7 +67,8 @@ public static class PatientSession ? new PatientTagResolver(picked, session, PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked), PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked), - PatientIdentity.HospitalNameOf(picked)) + PatientIdentity.HospitalNameOf(picked), + PatientIdentity.DiagnosisSourceFor(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 2c6137c..df91122 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -163,10 +163,22 @@ public sealed class PatientTagResolver : ITagValueResolver /// 요양기관명(HspInsNam) — 못 읽었으면 빈 문자열 private readonly string hospitalName; + /// + /// 상병 행 공급자 — 주/부/전체 별로 물을 때 한 번만 읽는다. + /// 세 갈래를 미리 다 읽지 않는 이유: 상병 태그가 없는 서식이 다수인데 + /// 환자를 붙일 때마다 왕복 셋을 무조건 내는 것은 낭비다. + /// 전체(T)에서 주/부를 클라이언트에서 가르지 않는 이유: 레거시는 필터를 + /// ROW_NUMBER 접기 에 넣으므로 같은 코드가 주·부 양쪽에 있으면 결과가 갈린다. + /// + private readonly Func>>? diagnosisSource; + + private readonly Dictionary>> diagnosisCache = new(); + public PatientTagResolver(PatientContext context, ITagValueResolver next, string residentNumber = "", IReadOnlyDictionary? address = null, IReadOnlyDictionary? doctor = null, - IReadOnlyDictionary? department = null, string hospitalName = "") + IReadOnlyDictionary? department = null, string hospitalName = "", + Func>>? diagnosisSource = null) { this.context = context; this.next = next; @@ -175,6 +187,7 @@ public sealed class PatientTagResolver : ITagValueResolver this.doctor = doctor ?? new Dictionary(StringComparer.OrdinalIgnoreCase); this.department = department ?? new Dictionary(StringComparer.OrdinalIgnoreCase); this.hospitalName = hospitalName; + this.diagnosisSource = diagnosisSource; } #endregion @@ -198,6 +211,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return facilityValue; } + if (FromDiagnosis(tag) is { } diagnosisValue) + { + return diagnosisValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -404,6 +421,88 @@ public sealed class PatientTagResolver : ITagValueResolver } } + /// + /// 상병 11종 — 해당 태그가 아니면 null. 같은 결과셋을 다르게 자를 뿐이다. + /// + /// 결합 규칙(전부 본문에서 확인): + /// · 가로 = ", " 구분(:OCM_상병명_가로 else 가지), 세로 = 줄바꿈(AppendLine) + /// · 한글명에는 확→(확진)·의→(의증) 접미, 영어명·코드에는 접미 없음 + /// · 주상병(첫 행): 명은 접미 없이 KcdKorNam(:10126 Case Else), 코드는 KcdElcCod(:9744) + /// + /// 병원 분기를 얼버무리지 않는다. 쿼리 자체가 갈리는 5병원과 + /// 이름·코드 규칙이 갈리는 JEGG·BSGH 는 미이식이라고 말한다 — + /// Else 갈래로 뭉개면 그 병원 서식에 다른 모양의 상병이 조용히 찍힌다. + /// + private TagValue? FromDiagnosis(string tag) + { + var scope = tag switch + { + "OCM_상병명_가로" or "OCM_상병명_세로" or "OCM_상병명_영어_가로" or "OCM_상병명_영어_세로" + or "OCM_상병코드_가로" or "OCM_상병코드_세로" => DiagnosisStore.Scope.All, + "OCM_부상병코드" or "OCM_부상병POA" => DiagnosisStore.Scope.Sub, + "OCM_주상병명" or "OCM_주상병코드" or "OCM_주상병POA" => DiagnosisStore.Scope.Main, + _ => (DiagnosisStore.Scope?)null, + } is { } s ? s : (DiagnosisStore.Scope?)null; + if (scope is null) + { + return null; + } + var hsp = UserSession.Current.HspCod; + if (hsp is "KIMEYE" or "NYJB" or "SYBS" or "HIMCHAN_CW" or "SPHH" + || (hsp == "JEGG" && tag is "OCM_상병명_세로" or "OCM_주상병명") + || (hsp == "BSGH" && tag == "OCM_주상병코드")) + { + return new TagValue(false, $"이 병원({hsp})의 상병 분기는 아직 옮기지 않았습니다"); + } + if (diagnosisSource is null) + { + return new TagValue(false, "상병 조회가 연결되지 않았습니다"); + } + if (!diagnosisCache.TryGetValue(scope.Value, out var rows)) + { + try + { + rows = diagnosisSource(scope.Value); + } + catch (Exception ex) + { + return new TagValue(false, $"상병 조회에 실패했습니다: {ex.GetType().Name}"); + } + diagnosisCache[scope.Value] = rows; + } + if (rows.Count == 0) + { + return new TagValue(false, "이 내원에 등록된 상병이 없습니다"); + } + + static string Cell(Dictionary r, string c) => r.TryGetValue(c, out var v) ? v.Trim() : string.Empty; + + static string Suffix(Dictionary r) => Cell(r, "OkdCfmSus") switch + { + "확" => "(확진)", + "의" => "(의증)", + _ => string.Empty, + }; + + var value = tag switch + { + "OCM_상병명_가로" => string.Join(", ", rows.Select(r => Cell(r, "KcdKorNam") + Suffix(r))), + "OCM_상병명_세로" => string.Join(Environment.NewLine, rows.Select(r => Cell(r, "KcdKorNam") + Suffix(r))), + "OCM_상병명_영어_가로" => string.Join(", ", rows.Select(r => Cell(r, "KcdEngNam"))), + "OCM_상병명_영어_세로" => string.Join(Environment.NewLine, rows.Select(r => Cell(r, "KcdEngNam"))), + "OCM_상병코드_가로" => string.Join(", ", rows.Select(r => Cell(r, "KcdElcCod"))), + "OCM_상병코드_세로" => string.Join(Environment.NewLine, rows.Select(r => Cell(r, "KcdElcCod"))), + "OCM_부상병코드" => string.Join(Environment.NewLine, rows.Select(r => Cell(r, "KcdElcCod"))), + "OCM_부상병POA" => string.Join(Environment.NewLine, rows.Select(r => Cell(r, "OkdPoaTyp"))), + "OCM_주상병명" => Cell(rows[0], "KcdKorNam"), + "OCM_주상병코드" => Cell(rows[0], "KcdElcCod"), + _ => Cell(rows[0], "OkdPoaTyp"), + }; + return value.Trim().Length > 0 + ? new TagValue(true, value) + : new TagValue(false, "상병 행은 있는데 그 값이 비어 있습니다"); + } + /// 주민번호에서 나온 값 — 비면 비었는지 말한다 private TagValue Wrap(string value) => value.Length > 0 ? new TagValue(true, value) @@ -421,6 +520,9 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_진료과", "ETC_요양기관명칭_병원명", "OCM_담당의사_영문", "OCM_담당의사면허번호", "OCM_담당의사전문의번호", "OCM_User진료과", "OCM_User진료과_한글명칭", "OCM_User진료의사명", + "OCM_상병명_가로", "OCM_상병명_세로", "OCM_상병명_영어_가로", "OCM_상병명_영어_세로", + "OCM_상병코드_가로", "OCM_상병코드_세로", "OCM_부상병코드", "OCM_부상병POA", + "OCM_주상병명", "OCM_주상병코드", "OCM_주상병POA", }; /// diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index 65913e2..6f28570 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -224,7 +224,8 @@ public partial class PreviewWindow : Window : new PatientTagResolver(picked, session, PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked), PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked), - PatientIdentity.HospitalNameOf(picked)); + PatientIdentity.HospitalNameOf(picked), + PatientIdentity.DiagnosisSourceFor(picked)); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document,