From ba6da2252fa1e983f3c4a3daed464ee208055459 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 16:18:37 +0900 Subject: [PATCH] =?UTF-8?q?=EB=B3=91=EB=A6=AC=ED=8C=90=EB=8F=85=EC=9D=98?= =?UTF-8?q?=C2=B7=EC=A7=84=EB=8B=A8=EA=B2=80=EC=82=AC=EC=9D=98=204?= =?UTF-8?q?=EC=A2=85=20=E2=80=94=20106=EC=A2=85=20=E2=86=92=20110=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ETC_병리판독의사명/전문의번호(bzDataInterface.vb:17318/:17515 — M_UidMst UidDtrYon='Y' AND UidDepCod='TLAB' + 유효기간), ETC_진단검사의사명/ 전문의번호(:17550/:17655 — M_DepMst DepGrpCod='LAB' 조인 + UidLicNum IS NOT NULL). 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출하므로 PatientTagResolver 에 둔다(lazy 소스 + 캐시 — 이 태그가 없는 서식이 대부분이라 프리페치하지 않는다). 의도적 이탈 하나: 원문엔 ORDER BY 가 없어 TLAB 의사가 둘이면 이름과 전문의번호가 서로 다른 사람이 될 수 있었다(태그마다 따로 조회하므로). ORDER BY UidCod + 1행 고정으로 두 태그가 반드시 같은 사람을 가리키게 했다 — 담당의 때 정한 결정 규칙의 세 번째 적용. - dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 110종) - --db-patient ①~㉚ 전건 통과 — ㉚ 신설: 두 조회가 예외 없이 돈다 (이 시험 DB 는 TLAB·LAB 의사 0행 — 태그는 사유로 완결되는 정상 경로) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Fable 5 --- .../Stores/PatientContextStore.cs | 32 ++++++++++ src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 23 +++++++ .../Services/PatientIdentity.cs | 17 +++++ .../Services/PatientSession.cs | 3 +- .../Services/PatientTagResolver.cs | 62 ++++++++++++++++++- .../Views/PreviewWindow.xaml.cs | 3 +- 6 files changed, 137 insertions(+), 3 deletions(-) diff --git a/src/SheetMe.Data/Stores/PatientContextStore.cs b/src/SheetMe.Data/Stores/PatientContextStore.cs index 500c781..43f1066 100644 --- a/src/SheetMe.Data/Stores/PatientContextStore.cs +++ b/src/SheetMe.Data/Stores/PatientContextStore.cs @@ -226,6 +226,38 @@ public sealed class PatientContextStore ("d", code), ("y", date[..8])); } + /// + /// 병리판독의(TLAB) 한 명 — ETC_병리판독의사명/전문의번호(bzDataInterface.vb:17318, 17515). + /// 원문에 ORDER BY 가 없어 TLAB 의사가 둘이면 이름과 번호가 서로 다른 사람이 될 수 있었다 + /// (태그마다 따로 조회했으므로). 한 행을 UidCod 순으로 고정해 두 태그가 같은 사람을 가리키게 한다. + /// + public IReadOnlyDictionary PathologyDoctor() + { + using var connection = new OracleConnection(connectionString); + connection.Open(); + return Row(connection, + "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidDtrYon = 'Y' AND TRIM(UidDepCod) = 'TLAB'" + + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte" + + " ORDER BY UidCod) WHERE ROWNUM = 1"); + } + + /// + /// 진단검사의(부서그룹 LAB) 한 명 — ETC_진단검사의사명/전문의번호(:17550, 17655). + /// 조건은 원문 그대로(의사 + 면허 보유 + 유효기간 + LAB 그룹 부서), 선택 규칙만 고정한다. + /// + public IReadOnlyDictionary LabDoctor() + { + using var connection = new OracleConnection(connectionString); + connection.Open(); + return Row(connection, + "SELECT * FROM (SELECT u.* FROM M_UidMst u" + + " INNER JOIN M_DepMst d ON u.UidDepCod = d.DepCod AND d.DepGrpCod = 'LAB'" + + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte" + + " WHERE u.UidDtrYon = 'Y' AND u.UidLicNum IS NOT NULL" + + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte" + + " ORDER BY u.UidCod) WHERE ROWNUM = 1"); + } + /// /// 수술 일정(S_OprInf) 한 행 — OCM_수술일자·ETC_수술일자_* 계열이 쓴다. /// WHERE OprComNum = :c AND OprStt = 'E' ORDER BY OprKey — diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 36f757f..bad69f7 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2291,6 +2291,29 @@ public static class DbSmoke Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0, "EmdWeight 널 필터나 조인을 의심할 것"); } + + // ㉚ 병리판독의·진단검사의 — 환자와 무관한 원내 의사 조회. + // 이 시험 DB 에 TLAB 소속·LAB 그룹 의사가 없을 수 있다 — + // 0행은 실패가 아니라 태그가 사유로 완결되는 정상 경로다. + // 여기서 지키는 것은 "예외 없이 돈다"(컬럼·조인 이름이 맞다)이다. + try + { + var contextStore = new PatientContextStore(conn); + var pathologyRow = contextStore.PathologyDoctor(); + var labRow = contextStore.LabDoctor(); + static string Shape(IReadOnlyDictionary row) + => row.Count == 0 ? "0행" + : $"이름 {(row.TryGetValue("UidNam", out var n) ? n.Trim().Length : 0)}자" + + $"·전문의번호 {(row.TryGetValue("UidSpcLic", out var s) ? s.Trim().Length : 0)}자"; + lines.Add($" 병리판독의(TLAB): {Shape(pathologyRow)}" + + $" / 진단검사의(LAB그룹): {Shape(labRow)} (값은 기록하지 않는다)"); + Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", true, ""); + } + catch (Exception ex) + { + Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", false, + $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); + } } } } diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index f7ae446..a717bf3 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -161,6 +161,23 @@ public static class PatientIdentity : new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order); } + /// 병리판독의·진단검사의 공급자 — 환자와 무관하지만 환자 태그로 노출된다 + public static Func> PathologyDoctorSource() + { + var connection = ConfigService.Current.ConnectionString; + return () => connection.Length == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new PatientContextStore(connection).PathologyDoctor(); + } + + public static Func> LabDoctorSource() + { + var connection = ConfigService.Current.ConnectionString; + return () => connection.Length == 0 + ? new Dictionary(StringComparer.OrdinalIgnoreCase) + : new PatientContextStore(connection).LabDoctor(); + } + /// 수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회 public static Func> ScheduledSurgerySourceFor(PatientContext context) { diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index e4705bc..03e7dac 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -73,7 +73,8 @@ public static class PatientSession PatientIdentity.SurgerySourceFor(picked), PatientIdentity.ReviewDiagnosisSourceFor(picked), PatientIdentity.VitalSourceFor(picked), - PatientIdentity.ScheduledSurgerySourceFor(picked)) + PatientIdentity.ScheduledSurgerySourceFor(picked), + PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource()) : session; return (tags, new MDataTableRunner(document, Variables())); } diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index e8acd66..f9f460f 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -195,6 +195,13 @@ public sealed class PatientTagResolver : ITagValueResolver private readonly Dictionary> scheduledSurgeryCache = new(); + /// 병리판독의(TLAB)·진단검사의(LAB) 공급자 — 각 1회 캐시 + private readonly Func>? pathologyDoctorSource; + + private readonly Func>? labDoctorSource; + + private readonly Dictionary> specialDoctorCache = new(StringComparer.Ordinal); + private readonly Dictionary>> reviewCache = new(); public PatientTagResolver(PatientContext context, ITagValueResolver next, @@ -207,7 +214,9 @@ public sealed class PatientTagResolver : ITagValueResolver Func>>? surgerySource = null, Func>>? reviewSource = null, Func? vitalSource = null, - Func>? scheduledSurgerySource = null) + Func>? scheduledSurgerySource = null, + Func>? pathologyDoctorSource = null, + Func>? labDoctorSource = null) { this.context = context; this.next = next; @@ -222,6 +231,8 @@ public sealed class PatientTagResolver : ITagValueResolver this.reviewSource = reviewSource; this.vitalSource = vitalSource; this.scheduledSurgerySource = scheduledSurgerySource; + this.pathologyDoctorSource = pathologyDoctorSource; + this.labDoctorSource = labDoctorSource; } #endregion @@ -265,6 +276,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return scheduleValue; } + if (FromSpecialDoctor(tag) is { } specialValue) + { + return specialValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -816,6 +831,49 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); } + /// + /// 병리판독의·진단검사의 4종 — 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로 노출한다. + /// 이름과 번호가 같은 행에서 나온다(레거시는 태그마다 따로 조회해 다른 사람이 될 수 있었다). + /// + private TagValue? FromSpecialDoctor(string tag) + { + var pathology = tag is "ETC_병리판독의사명" or "ETC_병리판독의사전문의번호"; + var lab = tag is "ETC_진단검사의사명" or "ETC_진단검사의사전문의번호"; + if (!pathology && !lab) + { + return null; + } + var source = pathology ? pathologyDoctorSource : labDoctorSource; + var cacheKey = pathology ? "P" : "L"; + if (source is null) + { + return new TagValue(false, "원내 의사 조회가 연결되지 않았습니다"); + } + if (!specialDoctorCache.TryGetValue(cacheKey, out var row)) + { + try + { + row = source(); + } + catch (Exception ex) + { + return new TagValue(false, $"원내 의사 조회에 실패했습니다: {ex.GetType().Name}"); + } + specialDoctorCache[cacheKey] = row; + } + if (row.Count == 0) + { + return new TagValue(false, pathology + ? "TLAB(병리) 소속의 유효한 의사가 없습니다" + : "LAB 그룹 부서의 유효한 의사가 없습니다"); + } + var column = tag.EndsWith("전문의번호", StringComparison.Ordinal) ? "UidSpcLic" : "UidNam"; + var value = row.TryGetValue(column, out var v) ? v.Trim() : string.Empty; + return value.Length > 0 + ? new TagValue(true, value) + : new TagValue(false, $"의사 행은 있는데 {column} 값이 비어 있습니다"); + } + private TagValue? FromVital(string tag) { // 머리둘레 두 종은 레거시 결함이다 — SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽어 @@ -886,6 +944,8 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_머리둘레", "OCM_머리둘레_LAST", "OCM_수술일자", "OCM_수술일자_마지막수술", "ETC_수술일자_1_몇년", "ETC_수술일자_1_몇년2자리", "ETC_수술일자_2_몇월", "ETC_수술일자_3_몇일", + "ETC_병리판독의사명", "ETC_병리판독의사전문의번호", + "ETC_진단검사의사명", "ETC_진단검사의사전문의번호", }; /// diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index 7b7c62c..2a13915 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -230,7 +230,8 @@ public partial class PreviewWindow : Window PatientIdentity.SurgerySourceFor(picked), PatientIdentity.ReviewDiagnosisSourceFor(picked), PatientIdentity.VitalSourceFor(picked), - PatientIdentity.ScheduledSurgerySourceFor(picked)); + PatientIdentity.ScheduledSurgerySourceFor(picked), + PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource()); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document,