diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs
index c03343e..3bfea53 100644
--- a/src/SheetMe.Data/Stores/PatientExtraStore.cs
+++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs
@@ -385,6 +385,147 @@ public sealed class PatientExtraStore
+ " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1",
("m", comNum));
+ ///
+ /// 계산유형 이름(OCM_계산유형, :5133) — 자격의 보험·계산 코드로 M_CalMst.CalNam.
+ /// 원문 Rows(0) 에 ORDER BY 가 없다 — 시작일 최신 행으로 고정한다.
+ ///
+ public string CalculationTypeName(string insCod, string calCod, string strDte)
+ => Scalar(
+ "SELECT v FROM (SELECT CalNam v FROM M_CalMst"
+ + " WHERE CalInsCod = :i AND CalCod = :l AND :d BETWEEN CalStrDte AND CalEndDte"
+ + " ORDER BY CalStrDte DESC) WHERE ROWNUM = 1",
+ ("i", insCod), ("l", calCod), ("d", strDte));
+
+ /// 보험유형 이름(OCM_보험유형, :5186) — M_InsMst.InsNam. 같은 규칙으로 고정
+ public string InsuranceTypeName(string insCod, string strDte)
+ => Scalar(
+ "SELECT v FROM (SELECT InsNam v FROM M_InsMst"
+ + " WHERE InsCod = :i AND :d BETWEEN InsStrDte AND InsEndDte"
+ + " ORDER BY InsStrDte DESC) WHERE ROWNUM = 1",
+ ("i", insCod), ("d", strDte));
+
+ ///
+ /// 수납일시(OCM_수납일시, :5287) — 자격에 걸린 수납청구(P_RblInf, RblCalNxt=0)의
+ /// 수납(P_RcpInf) 갱신일시 12자리. 원문은 두 쿼리 모두 ORDER BY 없는 Rows(0) —
+ /// 청구·수납 다건이면 임의였다. 최신 청구의 최신 수납으로 고정하고 한 왕복으로 접었다.
+ ///
+ public string ReceiptStamp(decimal comNum, string mtiSeq, string strDte)
+ => Scalar(
+ "SELECT v FROM (SELECT RcpUpdDtf v FROM P_RcpInf WHERE RcpRblNum IN ("
+ + " SELECT n FROM (SELECT RblNum n FROM P_RblInf"
+ + " WHERE RblComNum = :m AND RblMtiSeq = :s AND RblStrDte = :d AND RblCalNxt = 0"
+ + " ORDER BY RblNum DESC) WHERE ROWNUM = 1)"
+ + " ORDER BY RcpUpdDtf DESC) WHERE ROWNUM = 1",
+ ("m", comNum), ("s", mtiSeq), ("d", strDte));
+
+ ///
+ /// 최신 퇴원약 오더 일시(OCM_진료기간 SRCH 갈래, :2299-2318) — 인라인뷰 정렬이
+ /// 원문부터 있어 결정적이다. 필터(처방의약·숨김 제외 등)는 원문 그대로.
+ ///
+ public string DischargeMedicationOrderDate(decimal comNum)
+ => Scalar(
+ "SELECT v FROM (SELECT OdrDtm v FROM O_OdrInf"
+ + " WHERE OdrComNum = :m AND OdrStt = 'OE'"
+ + " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
+ + " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ + " AND OdrGrpSub = 0"
+ + " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
+ + " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
+ + " ORDER BY OdrDtm DESC) WHERE ROWNUM = 1",
+ ("m", comNum));
+
+ ///
+ /// 외래 진료기간 시작(OCM_진료기간 외래 갈래, :2325-2345) — 이 내원 이하의 취소 아닌
+ /// 최초 접수일시. OCM_최초내원일과 달리 진료행 조인·재진 필터가 없다(원문 그대로 —
+ /// ComNfsTyp='N' 조건은 주석 처리돼 있어 옮기지 않는다).
+ ///
+ public string TreatmentPeriodStart(string chtNum, string acpDtm)
+ => Scalar(
+ "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a AND ComAcpStt <> 'OC'"
+ + " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
+ ("c", chtNum), ("a", acpDtm));
+
+ /// 외래 진료기간 끝(:2352-2372) — 시작 이후 오늘(2359)까지의 최신 접수일시
+ public string TreatmentPeriodEnd(string chtNum, string strDte)
+ => Scalar(
+ "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm >= :s"
+ + " AND ComAcpDtm <= TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' AND ComAcpStt <> 'OC'"
+ + " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
+ ("c", chtNum), ("s", strDte));
+
+ ///
+ /// 응급증상 오더의 산정 여부(OCM_응급증상여부_YES/NO, :12748/:12830) — 응급 내원에
+ /// 걸린 응급의료관리료 오더 중 한 행의 OdrInsYon. 행이 없으면 null(레거시 $NoCheck$ —
+ /// 체크 상태를 정하지 않는다). 원문 Rows(0) 에 ORDER BY 가 없다 — 오더일시 최신으로
+ /// 고정한다. 코드 목록은 레거시 GetDtlMst("EMGEMGCOD") 그대로: 코드표에 있으면 그것,
+ /// 없으면 하드코딩 6종(레거시 폴백).
+ ///
+ public string? EmergencySymptomInsYon(decimal comNum)
+ {
+ var codes = Rows("SELECT DtlCod FROM M_DtlMst WHERE DtlTblCod = 'EMGEMGCOD' ORDER BY DtlCod")
+ .Select(r => r.TryGetValue("DtlCod", out var v) ? v : string.Empty)
+ .Where(v => v.Length > 0)
+ .ToArray();
+ if (codes.Length == 0)
+ {
+ codes = new[] { "V1200", "V1300", "V1400", "V1500", "AC103", "AC105" };
+ }
+ var placeholders = string.Join(", ", codes.Select((_, i) => $":g{i}"));
+ var binds = new List<(string, object)> { ("m", comNum) };
+ for (var i = 0; i < codes.Length; i++)
+ {
+ binds.Add(($"g{i}", codes[i]));
+ }
+ var value = Scalar(
+ "SELECT v FROM (SELECT OdrInsYon v FROM S_EmgInf OCMEMG"
+ + " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ + " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ + " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ + " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ + " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ + " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ + " WHERE OCMEMG.EmgComNum = :m"
+ + " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ + $" AND OdrElcCod IN ({placeholders})"
+ + " ORDER BY OdrDtm DESC, OdrKey DESC) WHERE ROWNUM = 1",
+ binds.ToArray());
+ // 빈 문자열은 "행은 있는데 값이 NULL"과 "행이 없다"를 못 가른다 — COUNT 로 가른다
+ if (value.Length > 0)
+ {
+ return value;
+ }
+ var count = Scalar(
+ "SELECT COUNT(*) FROM S_EmgInf OCMEMG"
+ + " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ + " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ + " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ + " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ + " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ + " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ + " WHERE OCMEMG.EmgComNum = :m"
+ + " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ + $" AND OdrElcCod IN ({placeholders})",
+ binds.ToArray());
+ return count == "0" ? null : string.Empty;
+ }
+
+ ///
+ /// 전과 내역(OCM_전과내역, :1103) — 진료 행 전부를 과명·구간과 함께.
+ /// 원문 조인 그대로: 과 조인이 담당의 조인의 병원 코드에 종속돼(DepHspCod=Usr.UidHspCod)
+ /// 의사 매칭이 빠지면 과명도 빈다. 정렬(CodStrDtm)이 원문부터 있어 결정적이다.
+ ///
+ public IReadOnlyList> TransferHistory(decimal comNum)
+ => Rows(
+ "SELECT DepKorNam AS DepNam, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
+ + " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
+ + " AND Cod.CodEndDtm BETWEEN Usr.UidStrDte AND Usr.UidEndDte || '2359'"
+ + " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
+ + " AND DepWrkTyp = 'D' AND Cod.CodEndDtm BETWEEN DepStrDte AND DepEndDte || '2359'"
+ + " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
+ ("m", comNum));
+
/// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index 0985b79..8fbe010 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -2437,6 +2437,13 @@ public static class DbSmoke
("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()),
("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()),
("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()),
+ ("계산유형", () => extra.CalculationTypeName("1", "01", "20260101").Length.ToString()),
+ ("보험유형", () => extra.InsuranceTypeName("1", "20260101").Length.ToString()),
+ ("수납일시", () => owner2 is null ? "-" : extra.ReceiptStamp(owner2.Value, "0", "20260101").Length.ToString()),
+ ("진료기간시작", () => extra.TreatmentPeriodStart(extraOwner, "999912312359").Length.ToString()),
+ ("퇴원약오더", () => owner2 is null ? "-" : extra.DischargeMedicationOrderDate(owner2.Value).Length.ToString()),
+ ("응급증상", () => owner2 is null ? "-" : (extra.EmergencySymptomInsYon(owner2.Value) ?? "[없음]")),
+ ("전과내역", () => owner2 is null ? "-" : extra.TransferHistory(owner2.Value).Count.ToString()),
};
var shapes2 = new List();
var failed = new List();
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
index 906b5b0..746c7a2 100644
--- a/src/SheetMe.Designer/Services/PatientTagResolver.cs
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -1380,6 +1380,9 @@ public sealed class PatientTagResolver : ITagValueResolver
"OCM_감염분류_감염정보", "OCM_감염정보",
"OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"OCM_병실인실", "OCM_병실차액",
+ "OCM_계산유형", "OCM_보험유형", "OCM_수납일시",
+ "OCM_진료기간", "OCM_진료기간_진료과",
+ "OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역",
};
///
@@ -1638,6 +1641,160 @@ public sealed class PatientTagResolver : ITagValueResolver
? Wrap(store.RoomExtraCharge(m), "병실 등급·산정 마스터가 짝이 되는 행이 없습니다")
: new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
}
+ case "OCM_계산유형":
+ case "OCM_보험유형":
+ case "OCM_수납일시":
+ {
+ // 자격의 코드·시작일은 문맥 CoiInf 행 — 레거시의 시점 분기
+ // (퇴원/현재/접수, :5148-5156)와 같은 판정이다.
+ // 레거시는 자격 0행이면 Rows(0) 예외 → 오류창 → "" 였다.
+ if (context.CoiInf.Count == 0)
+ {
+ return new TagValue(false, "이 내원에 보험 자격(P_CoiInf) 행이 없습니다");
+ }
+ var insCod = PatientContext.Value(context.CoiInf, "CoiInsCod");
+ var strDte = PatientContext.Value(context.CoiInf, "CoiStrDte");
+ if (tag == "OCM_계산유형")
+ {
+ return Wrap(store.CalculationTypeName(insCod,
+ PatientContext.Value(context.CoiInf, "CoiCalCod"), strDte),
+ "계산 마스터(M_CalMst)에 짝이 되는 행이 없습니다");
+ }
+ if (tag == "OCM_보험유형")
+ {
+ return Wrap(store.InsuranceTypeName(insCod, strDte),
+ "보험 마스터(M_InsMst)에 짝이 되는 행이 없습니다");
+ }
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ var stamp = store.ReceiptStamp(m,
+ PatientContext.Value(context.CoiInf, "CoiMtiSeq"), strDte);
+ return stamp.Length >= 12
+ ? new TagValue(true, Stamp(stamp))
+ : new TagValue(false, "이 자격의 수납(P_RcpInf) 기록이 없습니다");
+ }
+ case "OCM_진료기간":
+ {
+ // SRCH 갈래(:2295-2323)와 외래 공통 갈래(:2325-2380)를 옮겼다.
+ // HANKOOK·SRH·GUPOSS·Case Else 분기는 우리 병원이 아니라 미이식.
+ var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
+ if (acp.Length < 8)
+ {
+ return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
+ }
+ var start = Hyphenate(acp[..8]);
+ var today = PatientContext.Value(hospital, "ServerToday");
+ if (Com(context, "ComPatTyp") == "I")
+ {
+ if (Com(context, "ComPrgStt") == "ILV")
+ {
+ var lev = Com(context, "ComLevDtm");
+ return new TagValue(true, $"{start} ~ {Hyphenate(Head(lev, 8))}");
+ }
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ // 재원 중 — 끝은 최신 퇴원약 오더일 → 퇴원예정일 → 오늘 순(:2299-2321)
+ var order = store.DischargeMedicationOrderDate(m);
+ var end = order.Length >= 8 ? order[..8]
+ : Com(context, "ComLevSch").Length >= 8 ? Com(context, "ComLevSch")[..8]
+ : today;
+ return end.Length >= 8
+ ? new TagValue(true, $"{start} ~ {Hyphenate(end[..8])}")
+ : new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
+ }
+ // 외래 — 최초 접수 ~ (그 이후 최신 접수 또는 오늘)
+ var first = store.TreatmentPeriodStart(chtNum, acp);
+ if (first.Length < 8)
+ {
+ return new TagValue(false, "취소 아닌 이전 내원이 없습니다");
+ }
+ var last = store.TreatmentPeriodEnd(chtNum, first);
+ var tail = last.Length >= 8 ? last[..8] : today;
+ return tail.Length >= 8
+ ? new TagValue(true, $"{Hyphenate(first[..8])} ~ {Hyphenate(tail[..8])}")
+ : new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
+ }
+ case "OCM_진료기간_진료과":
+ {
+ // 입원 갈래(:2600-2610)만 값이 된다. 외래 갈래는 두 번째 쿼리의
+ // 진료과 바인드(sDepCod)가 파라미터에 등록되지 않아(레거시 결함,
+ // :2644-2647) 항상 ORA-01008 → 오류창 → 빈 값이었다 — 사유로 말한다.
+ if (Com(context, "ComPatTyp") != "I")
+ {
+ return new TagValue(false,
+ "레거시 결함(진료과 바인드 미등록)으로 외래에서는 항상 빈 값인 태그입니다");
+ }
+ var acp = PatientContext.Value(context.ComInf, "ComAcpDtm");
+ if (acp.Length < 8)
+ {
+ return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다");
+ }
+ var start = Hyphenate(acp[..8]);
+ if (Com(context, "ComPrgStt") == "ILV")
+ {
+ return new TagValue(true,
+ $"{start} ~ {Hyphenate(Head(Com(context, "ComLevDtm"), 8))}");
+ }
+ var today = PatientContext.Value(hospital, "ServerToday");
+ return today.Length >= 8
+ ? new TagValue(true, $"{start} ~ {Hyphenate(today)}")
+ : new TagValue(false, "요양기관 행(서버 오늘)을 읽지 못했습니다");
+ }
+ case "OCM_응급증상여부_YES":
+ case "OCM_응급증상여부_NO":
+ {
+ // 체크박스 태그다: "Y"=체크, ""=언체크, 행 없음=상태를 정하지 않음
+ // (레거시 $NoCheck$ — 사유로 돌려주면 체크류 렌더가 컨트롤을 그대로 둔다).
+ // SJRCH 분기(:12878-12886)는 미이식 — Case Else 갈래만 옮겼다.
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ var insYon = store.EmergencySymptomInsYon(m);
+ if (insYon is null)
+ {
+ return new TagValue(false,
+ "응급의료관리료 오더가 없어 체크 여부를 정하지 않습니다");
+ }
+ var yes = insYon == "0";
+ return new TagValue(true,
+ (tag == "OCM_응급증상여부_YES") == yes ? "Y" : string.Empty);
+ }
+ case "OCM_전과내역":
+ {
+ var comNum = PatientContext.Value(context.ComInf, "ComNum");
+ if (!decimal.TryParse(comNum, out var m))
+ {
+ return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다");
+ }
+ var history = store.TransferHistory(m);
+ if (history.Count == 0)
+ {
+ return new TagValue(false, "진료 행(P_CodInf) 이력이 없습니다");
+ }
+ // 원문 결합(:1140-1150): 과명 + 공백 7 + 시작 ~ 끝, 행 간 CRLF(끝에는 없음)
+ var text = new System.Text.StringBuilder();
+ foreach (var row in history)
+ {
+ if (text.Length > 0)
+ {
+ text.Append("\r\n");
+ }
+ text.Append(PatientContext.Value(row, "DepNam"))
+ .Append(" ")
+ .Append(Stamp(PatientContext.Value(row, "CodStrDtm")))
+ .Append(" ~ ")
+ .Append(Stamp(PatientContext.Value(row, "CodEndDtm")));
+ }
+ return new TagValue(true, text.ToString());
+ }
case "OCM_LMP":
{
var comNum = PatientContext.Value(context.ComInf, "ComNum");
@@ -1814,6 +1971,8 @@ public sealed class PatientTagResolver : ITagValueResolver
"ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호",
"OCM_감염분류_감염정보", "OCM_감염정보", "OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명",
"OCM_병실인실", "OCM_병실차액", "OCM_진료과_전화번호", "OCM_전과일시", "OCM_전과일자",
+ "OCM_계산유형", "OCM_보험유형", "OCM_수납일시", "OCM_진료기간", "OCM_진료기간_진료과",
+ "OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역",
"OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간",
"OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시",
"OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",
diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs
index 5fd598d..4dfc49b 100644
--- a/src/SheetMe.Designer/Services/PrintService.cs
+++ b/src/SheetMe.Designer/Services/PrintService.cs
@@ -196,6 +196,13 @@ public static class PrintService
{
return control;
}
+ // 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
+ // ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
+ // "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
+ if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
+ {
+ return control;
+ }
var model = control.Model.Clone();
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{