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,