From 7f147a3012e878f1a84d2b8a61c94ee3aaad958a Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 17:16:00 +0900 Subject: [PATCH] =?UTF-8?q?=ED=98=88=EC=95=A1=ED=98=95=C2=B7LMP=C2=B7BST?= =?UTF-8?q?=C2=B7=EC=B2=B4=EC=A4=91=20=ED=8C=8C=EC=83=9D=208=EC=A2=85=20?= =?UTF-8?q?=E2=80=94=20156=EC=A2=85=20=E2=86=92=20164=EC=A2=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OCM_혈액형(bzDataInterface.vb:11783 — S_BlpInf.BlpAboTyp, 갱신일시 최신 고정), OCM_LMP(:11813 — O_PrgInf.PrgLmpDte, 같은 표를 읽는 임신주기의 갱신일시 최신순으로 고정), OCM_BST/BST_LAST(:12042/:12710 — 바이탈과 다른 표 E_EmdInf_BST, VitalStore 를 표 인자화), OCM_BMI(:12159 — 차트 전체 최신 체중/키², 반올림 2자리), OCM_표준체중_LAST(:12234 — (키/100)²×여21·남22), OCM_조정체중_LAST(:12284 — 표준+(실제-표준)×0.25, 키·체중이 같은 행), OCM_비만도(:12333 — 이것만 내원 기준, 체중/Round(표준,0)×100). BMI·표준·조정은 내원(EmrComNum)이 아니라 차트(EmrChtNum) 기준이다 — 주석 처리된 EmrComNum 이 그 흔적. 성별 불명이면 표준·조정은 레거시 그대로 "0" 이 찍히고, 비만도는 0 나눗셈이라 사유로 말한다. OCM_임신주기는 사용자별 레지스트리 설정(DB_REGISTRY, UidCod 컬럼에 UidNam 을 비교하는 수상한 조건 포함) 의존이라 보류 — 별도 조사 대상. - dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 164종) - --db-patient ①~㉞ 전건 통과 (S_BlpInf·O_PrgInf·E_EmdInf_BST 실행 확인) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Fable 5 --- src/SheetMe.Data/Stores/PatientExtraStore.cs | 22 +++ src/SheetMe.Data/Stores/VitalStore.cs | 27 ++- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 5 +- .../Services/PatientIdentity.cs | 19 ++ .../Services/PatientSession.cs | 3 +- .../Services/PatientTagResolver.cs | 170 +++++++++++++++++- .../Views/PreviewWindow.xaml.cs | 3 +- 7 files changed, 241 insertions(+), 8 deletions(-) diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs index 813c3c4..ba3d45e 100644 --- a/src/SheetMe.Data/Stores/PatientExtraStore.cs +++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs @@ -196,6 +196,28 @@ public sealed class PatientExtraStore + " ORDER BY DcpStrDte DESC) WHERE ROWNUM = 1", ("m", comNum)); + /// + /// 혈액형(OCM_혈액형, :11783) — S_BlpInf.BlpAboTyp. 원문 Rows(0) 에 ORDER BY 가 없다 — + /// 갱신일시 최신 행으로 고정한다(마지막 판정이 현재 혈액형이다). + /// BlpChtNum 은 VARCHAR2 라 저장 형태를 확정할 수 없어 두 형태 모두 본다. + /// + public string BloodType(string chtNum) + => Scalar( + "SELECT v FROM (SELECT BlpAboTyp v FROM S_BlpInf" + + " WHERE BlpChtNum IN (:c, RPAD(:c, 10))" + + " ORDER BY BlpUpdDtm DESC NULLS LAST) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// + /// 최종 월경일(OCM_LMP, :11813) — O_PrgInf.PrgLmpDte. 원문 Rows(0) 에 ORDER BY 가 없다 — + /// 같은 표를 읽는 임신주기(:11845)가 갱신일시 최신순을 쓰므로 그 정렬로 고정한다. + /// + public string Lmp(decimal comNum) + => Scalar( + "SELECT v FROM (SELECT PrgLmpDte v FROM O_PrgInf" + + " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1", + ("m", comNum)); + /// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙) private string Scalar(string sql, params (string Name, object Value)[] binds) { diff --git a/src/SheetMe.Data/Stores/VitalStore.cs b/src/SheetMe.Data/Stores/VitalStore.cs index ba08924..5a626a9 100644 --- a/src/SheetMe.Data/Stores/VitalStore.cs +++ b/src/SheetMe.Data/Stores/VitalStore.cs @@ -32,16 +32,37 @@ public sealed class VitalStore /// 정렬 절 — 예: "EmdDte ASC, EmdTime ASC" public string One(decimal comNum, string selectExpr, string pick, string[] notNullColumns, string orderBy) + => Fetch("E_EmdInf_VITAL", "EmrComNum = :k", comNum, selectExpr, pick, notNullColumns, orderBy); + + /// + /// BST 는 표가 다르다(E_EmdInf_BST, bzDataInterface.vb:12042-12080·12710-12747) — + /// 같은 조인 틀에 표만 바뀐다. + /// + public string OneBst(decimal comNum, string orderBy) + => Fetch("E_EmdInf_BST", "EmrComNum = :k", comNum, "EmdBst", "EmdBst", + new[] { "EmdBst" }, orderBy); + + /// + /// BMI·표준체중·조정체중은 내원이 아니라 차트 전체에서 최신 측정을 집는다 + /// (bzDataInterface.vb:12159-12333 — EmrChtNum 조건, 주석 처리된 EmrComNum 이 그 흔적). + /// + public string OneByChart(string chtNum, string selectExpr, string pick, + string[] notNullColumns, string orderBy) + => Fetch("E_EmdInf_VITAL", "EmrChtNum = RPAD(:k, 10)", chtNum, selectExpr, pick, + notNullColumns, orderBy); + + private string Fetch(string table, string where, object key, 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" + + $" FROM {table}" + " INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey" + " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D')" - + " WHERE EmrComNum = :iComNum" + + $" WHERE {where}" + filters + $" ORDER BY {orderBy}" + " ) WHERE ROWNUM = 1"; @@ -51,7 +72,7 @@ public sealed class VitalStore using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = sql; - command.Parameters.Add(new OracleParameter("iComNum", comNum)); + command.Parameters.Add(new OracleParameter("k", key)); using var reader = command.ExecuteReader(); if (!reader.Read()) { diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 91a09e9..65c0acd 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2358,7 +2358,10 @@ public static class DbSmoke + $" · 최초내원 {extra.FirstVisit(extraOwner, "999912312359").Length}자" + $" · 초진일 {extra.FirstVisitByDepartment(extraOwner, "999912312359", "00", false).Length}자" + (owner2 is null ? "" - : $" · 외출외박 {extra.LeaveOfAbsence(owner2.Value, false).Length}자"); + : $" · 외출외박 {extra.LeaveOfAbsence(owner2.Value, false).Length}자" + + $" · LMP {extra.Lmp(owner2.Value).Length}자" + + $" · BST {new VitalStore(conn).OneBst(owner2.Value, "EmdDte ASC, EmdTime ASC").Length}자") + + $" · 혈액형 {extra.BloodType(extraOwner).Length}자"; lines.Add($" 부가정보: {shapes} (값은 기록하지 않는다)"); Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", true, ""); diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index bb7cfb2..92f4a14 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -161,6 +161,25 @@ public static class PatientIdentity : new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order); } + /// BST 공급자 — 표가 달라(E_EmdInf_BST) 바이탈과 분리 + public static Func BstSourceFor(PatientContext context) + { + var connection = ConfigService.Current.ConnectionString; + return order => connection.Length == 0 + ? string.Empty + : new VitalStore(connection).OneBst(context.ComNum, order); + } + + /// 차트 전체 기준 바이탈 공급자 — BMI·표준·조정체중이 쓴다 + public static Func ChartVitalSourceFor(PatientContext context) + { + var connection = ConfigService.Current.ConnectionString; + var chtNum = PatientContext.Value(context.ComInf, "ComChtNum"); + return (expr, pick, notNull, order) => connection.Length == 0 || chtNum.Length == 0 + ? string.Empty + : new VitalStore(connection).OneByChart(chtNum, expr, pick, notNull, order); + } + /// 병리판독의·진단검사의 공급자 — 환자와 무관하지만 환자 태그로 노출된다 public static Func> PathologyDoctorSource() { diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index 6e7feed..8a7b25f 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -75,7 +75,8 @@ public static class PatientSession PatientIdentity.VitalSourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), - PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource()) + PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), + PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(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 b7741da..b33bfdc 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -239,6 +239,12 @@ public sealed class PatientTagResolver : ITagValueResolver private readonly Dictionary deptAtCache = new(StringComparer.Ordinal); + /// BST 공급자(정렬 → 값) — 표가 달라 바이탈 소스와 분리 + private readonly Func? bstSource; + + /// 차트 전체 기준 바이탈 공급자 — BMI·표준·조정체중이 쓴다 + private readonly Func? chartVitalSource; + private readonly Dictionary>> reviewCache = new(); public PatientTagResolver(PatientContext context, ITagValueResolver next, @@ -255,7 +261,9 @@ public sealed class PatientTagResolver : ITagValueResolver Func>? pathologyDoctorSource = null, Func>? labDoctorSource = null, Func? extraStoreSource = null, - Func? contextStoreSource = null) + Func? contextStoreSource = null, + Func? bstSource = null, + Func? chartVitalSource = null) { this.context = context; this.next = next; @@ -274,6 +282,8 @@ public sealed class PatientTagResolver : ITagValueResolver this.labDoctorSource = labDoctorSource; this.extraStoreSource = extraStoreSource; this.contextStoreSource = contextStoreSource; + this.bstSource = bstSource; + this.chartVitalSource = chartVitalSource; } #endregion @@ -333,6 +343,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return deptAtValue; } + if (FromBodyDerived(tag) is { } bodyValue) + { + return bodyValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -937,6 +951,147 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); } + /// + /// BST·BMI·체중 파생 6종(bzDataInterface.vb:12042-12420). + /// + /// BST 는 바이탈과 표가 다르고(E_EmdInf_BST), BMI·표준체중·조정체중은 내원이 아니라 + /// 차트 전체 최신 측정을 쓴다(EmrChtNum — 주석 처리된 EmrComNum 이 그 흔적). + /// 비만도만 내원(EmrComNum) 기준이다 — 원문이 그렇다. + /// + /// 표준·조정체중의 성별 계수(여 21·남 22)는 주민번호에서 나온다. 성별을 못 정하면 + /// 레거시는 계수 0 으로 계산해 "0" 이 종이에 찍힌다 — 그대로 옮겼다. + /// 비만도는 그 경우 0 나눗셈이라 레거시도 정상 값을 못 만든다 — 사유로 말한다. + /// + private TagValue? FromBodyDerived(string tag) + { + if (tag is not ("OCM_BST" or "OCM_BST_LAST" or "OCM_BMI" + or "OCM_표준체중_LAST" or "OCM_조정체중_LAST" or "OCM_비만도")) + { + return null; + } + if (extraCache.TryGetValue(tag, out var cached)) + { + return cached; + } + var value = Compute(); + extraCache[tag] = value; + return value; + + TagValue Compute() + { + try + { + switch (tag) + { + case "OCM_BST": + case "OCM_BST_LAST": + { + if (bstSource is null) + { + return new TagValue(false, "BST 조회가 연결되지 않았습니다"); + } + var bst = bstSource(tag == "OCM_BST" + ? "EmdDte ASC, EmdTime ASC" : "EmdDte DESC, EmdTime DESC"); + return bst.Length > 0 + ? new TagValue(true, bst) + : new TagValue(false, "이 내원에 BST 기록(E_EmdInf_BST)이 없습니다"); + } + case "OCM_비만도": + { + // 내원 기준(EmrComNum) — 키·체중을 따로 최신으로 집는다(:12333-12420) + if (vitalSource is null) + { + return new TagValue(false, "바이탈 조회가 연결되지 않았습니다"); + } + var h = Number(vitalSource("EmdHeight", "EmdHeight", + new[] { "EmdHeight" }, "EmdDte DESC, EmdTime DESC")); + var w = Number(vitalSource("EmdWeight", "EmdWeight", + new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC")); + if (h is null || w is null) + { + return new TagValue(false, "이 내원에 키·체중 측정이 없습니다"); + } + var std = StandardWeight(h.Value); + if (std is null or 0) + { + return new TagValue(false, "성별을 판정할 수 없어 표준체중을 만들 수 없습니다"); + } + var pct = Math.Round(w.Value / Math.Round(std.Value, 0) * 100, 1); + return new TagValue(true, pct.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + default: + { + // 차트 전체 최신(EmrChtNum) — BMI·표준체중·조정체중(:12159-12333) + if (chartVitalSource is null) + { + return new TagValue(false, "차트 바이탈 조회가 연결되지 않았습니다"); + } + if (tag == "OCM_표준체중_LAST") + { + var h = Number(chartVitalSource("EmdHeight", "EmdHeight", + new[] { "EmdHeight" }, "EmdDte DESC, EmdTime DESC")); + if (h is null) + { + return new TagValue(false, "이 차트에 키 측정이 없습니다"); + } + // 성별 불명이면 계수 0 → "0"(레거시 Select Case 의 빈 Case "") + var std = StandardWeight(h.Value) ?? 0; + return new TagValue(true, Math.Round(std, 1) + .ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + if (tag == "OCM_조정체중_LAST") + { + // 키와 체중이 같은 행에서 나온다(:12291-12303) — 결합식으로 한 번에 + var pair = chartVitalSource("EmdHeight || '/' || EmdWeight AS HW", "HW", + new[] { "EmdWeight", "EmdHeight" }, "EmdDte DESC, EmdTime DESC"); + var parts = pair.Split('/'); + var h = parts.Length == 2 ? Number(parts[0]) : null; + var w = parts.Length == 2 ? Number(parts[1]) : null; + if (h is null || w is null) + { + return new TagValue(false, "키와 체중이 함께 있는 측정이 없습니다"); + } + var std = StandardWeight(h.Value) ?? 0; + var abw = std + (w.Value - std) * 0.25; + return new TagValue(true, Math.Round(abw, 1) + .ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + // OCM_BMI — 체중과 키를 따로 최신으로(:12168-12216), 반올림 2자리 + var weight = Number(chartVitalSource("EmdWeight", "EmdWeight", + new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC")); + var height = Number(chartVitalSource("EmdHeight", "EmdHeight", + new[] { "EmdHeight" }, "EmdDte DESC, EmdTime DESC")); + if (weight is null || height is null || height.Value == 0) + { + return new TagValue(false, "이 차트에 키·체중 측정이 없습니다"); + } + var meters = height.Value / 100; + var bmi = Math.Round(weight.Value / (meters * meters), 2); + return new TagValue(true, bmi.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + } + } + catch (Exception ex) + { + return new TagValue(false, $"측정치 조회에 실패했습니다: {ex.GetType().Name}"); + } + } + + // 표준체중 = (키/100)² × (여 21 / 남 22) — 성별 불명이면 null(:12275-12283) + double? StandardWeight(double heightCm) + => ResidentNumber.SexOf(residentNumber) switch + { + "F" => heightCm / 100 * (heightCm / 100) * 21, + "M" => heightCm / 100 * (heightCm / 100) * 22, + _ => null, + }; + + static double? Number(string? value) + => double.TryParse((value ?? string.Empty).Trim(), + System.Globalization.NumberStyles.Float, + System.Globalization.CultureInfo.InvariantCulture, out var n) ? n : null; + } + /// /// 입원과·퇴원과 4종(bzDataInterface.vb:6009-6318) — 문맥의 적용일시와 다른 시점의 /// 진료과를 묻는다. 입원과는 접수 시점(전과했으면 처음 과), 퇴원과는 @@ -1195,7 +1350,7 @@ public sealed class PatientTagResolver : ITagValueResolver or "PAT_건보_급여_세대주명" or "PAT_협력업체" or "PAT_보호자연락처" or "OCM_FollowUp" or "OCM_외출외박신청일" or "OCM_외출외박종료일" or "OCM_최초내원일" or "OCM_초진일_발병일" or "OCM_신환_초진일" - or "OCM_입원일자_낮병동")) + or "OCM_입원일자_낮병동" or "OCM_혈액형" or "OCM_LMP")) { return null; } @@ -1292,6 +1447,15 @@ public sealed class PatientTagResolver : ITagValueResolver ? new TagValue(true, Hyphenate(day[..8])) : new TagValue(false, "낮병동 신청(P_DcpInf) 구간이 이 내원을 포함하지 않습니다"); } + case "OCM_혈액형": + return Wrap(store.BloodType(chtNum), "혈액형 판정(S_BlpInf) 행이 없습니다"); + case "OCM_LMP": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + return decimal.TryParse(comNum, out var m) + ? Wrap(store.Lmp(m), "임신 정보(O_PrgInf)에 최종 월경일이 없습니다") + : new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } case "OCM_FollowUp": { // 지금보다 뒤의 내원이 없으면 레거시도 "" 다(:1055) — 사유로 말한다 @@ -1453,6 +1617,8 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_진료과_그룹코드", "OCM_진료과_영어", "OCM_진료과대외명칭", "OCM_진료과약어명칭", "OCM_입원과", "OCM_입원과_한글명칭", "OCM_퇴원과", "OCM_퇴원과_한글명칭", "OCM_입원의사", "OCM_퇴원의사", "OCM_협진과", "OCM_협진과_협진의", + "OCM_혈액형", "OCM_LMP", "OCM_BST", "OCM_BST_LAST", + "OCM_BMI", "OCM_표준체중_LAST", "OCM_조정체중_LAST", "OCM_비만도", "OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간", "OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시", "OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동", diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index fdc0dcd..82d9c16 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -232,7 +232,8 @@ public partial class PreviewWindow : Window PatientIdentity.VitalSourceFor(picked), PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), - PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource()); + PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), + PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked)); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document,