diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs index ba3d45e..c03343e 100644 --- a/src/SheetMe.Data/Stores/PatientExtraStore.cs +++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs @@ -218,6 +218,173 @@ public sealed class PatientExtraStore + " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1", ("m", comNum)); + /// + /// 발병일(OCM_발병일, :8125) — 대표 주상병(OkdSeq=1, OkdStt='E')과 오늘 유효한 + /// 발병 기록(O_OnsInf)의 시작일. 원문 Rows(0) 에 ORDER BY 가 없다 — + /// 발병 시작일 최신 행으로 고정한다. + /// + public string OnsetDate(string chtNum, decimal comNum) + => Scalar( + "SELECT v FROM (SELECT OnsStrDte v FROM O_OkdInf" + + " INNER JOIN O_OnsInf ON OkdKcdCod = OnsKcdCod" + + " AND OnsChtNum = RPAD(:c, 10)" + + " AND (OnsDelDtf IS NULL OR OnsDelDtf = ' ')" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN OnsStrDte AND OnsEndDte" + + " WHERE OkdComNum = :m AND OkdSeq = 1 AND OkdStt = 'E'" + + " ORDER BY OnsStrDte DESC) WHERE ROWNUM = 1", + ("c", chtNum), ("m", comNum)); + + /// + /// 당일 식이(OCM_당일식이, :12620) — 오늘(SYSDATE) 입원 식이의 아침/점심/저녁 PIVOT 행. + /// PIVOT 이 내원 단위로 접으므로 결과는 최대 1행 — 원문 그대로 결정적이다. + /// + public IReadOnlyDictionary TodayMeals(decimal comNum) + => Row( + "WITH ComIml AS (SELECT A01.ImlMgdTyp MgdTyp, B01.MgdKorNam MgdNam, A01.ImlComNum" + + " FROM O_ImlInf A01 INNER JOIN M_MgdMst B01 ON MgdCod = ImlMgdCod" + + " WHERE ImlDte = TO_CHAR(SYSDATE, 'YYYYMMDD') AND ImlPatTyp = 'I' AND ImlComNum = :m)" + + " SELECT * FROM ComIml" + + " PIVOT (MAX(MgdNam) AS MgdTyp FOR MgdTyp IN ('B' 아침, 'L' 점심, 'D' 저녁))", + ("m", comNum)); + + /// + /// 알러지(OCM_알러지·_조영제·_약물·_음식·_기타, :14941-15140) — 오늘 유효한 코드마스터 + /// (ALLERGY)에 매칭되는 행을 SQL LISTAGG 로 CRLF 결합. category 가 null 이면 전체. + /// 집계 쿼리라 항상 1행 — 매칭 0건이면 NULL → 빈 문자열이다(원문과 같은 귀결). + /// + public string Allergy(string chtNum, string? category) + { + var filter = category is null ? string.Empty : " AND AlgAlgCtg = :g"; + var binds = category is null + ? new (string, object)[] { ("c", chtNum) } + : new (string, object)[] { ("c", chtNum), ("g", category) }; + return Scalar( + "SELECT LISTAGG(AlgAlgCtg || '-' || CASE WHEN NVL(AlgAlgSpm, ' ') <> ' '" + + " THEN AlgAlgTyp || ' (증상 : ' || AlgAlgSpm || ')' ELSE AlgAlgTyp END," + + " CHR(13) || CHR(10)) WITHIN GROUP (ORDER BY DtsDtlCod, DtsDspSeq)" + + " FROM S_AlgInf INNER JOIN M_DtsMst ON DtsTblCod = 'ALLERGY'" + + " AND AlgAlgTyp = DtsCodNam" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte" + + " WHERE AlgChtNum IN (:c, RPAD(:c, 10))" + + " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D')" + filter, + binds); + } + + /// + /// 알러지 원장 행들(OCM_알러지_List, :15142) — 코드마스터 조인 없이 전부, + /// 갱신일시 오름차순(원문 ORDER BY 그대로). 결합은 해석기가 원문 결함까지 흉내낸다. + /// + public IReadOnlyList> AllergyRows(string chtNum) + => Rows( + "SELECT AlgAlgCtg, AlgAlgTyp, AlgAlgSpm FROM S_AlgInf" + + " WHERE AlgChtNum IN (:c, RPAD(:c, 10))" + + " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D') ORDER BY AlgUpdDtf ASC", + ("c", chtNum)); + + /// + /// 외출 예상 일시(OCM_외출예상시작/종료일시, :15197/:15231) — 오늘 0시 이후 시작하는 + /// 외출·외박 신청 중 가장 이른 것. 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다. + /// 두 태그가 같은 행의 양 끝을 읽는다(정렬 키는 시작 쪽). + /// + public string ExpectedOuting(string chtNum, bool endSide) + => Scalar( + "SELECT " + (endSide ? "NVL(OutAdmEndDtm, OutAdmExpEndDtm)" : "NVL(OutAdmStrDtm, OutAdmExpStrDtm)") + + " FROM (SELECT * FROM O_OutAdmInf" + + " WHERE OutAdmChtNum IN (:c, RPAD(:c, 10))" + + " AND OutAdmStrDtm >= TO_CHAR(SYSDATE, 'YYYYMMDD') || '0000'" + + " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm)) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// + /// 건강보험증번호(ETC_건강보험증번호, :15269) — 건보(PisInsCod=1) 자격의 최신 순번 행. + /// 원문의 숫자 리터럴 비교(PisInsCod = 1)와 TO_NUMBER 정렬을 그대로 옮겼다. + /// + public string InsuranceCertNumber(string chtNum) + => Scalar( + "SELECT v FROM (SELECT Pis1AsylmSym v FROM P_PisInf" + + " WHERE PisInsCod = 1 AND PisChtNum = RPAD(:c, 10)" + + " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// 재해발생일(ETC_재해발생일, :15323) — 산재(PisInsCod=4) 자격의 최신 순번 행. YYYYMMDD 원문 그대로 + public string AccidentDate(string chtNum) + => Scalar( + "SELECT v FROM (SELECT Pis4AcdDte v FROM P_PisInf" + + " WHERE PisInsCod = 4 AND PisChtNum = RPAD(:c, 10)" + + " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// + /// 산재지정번호(ETC_산재지정번호, :15727) — 진료과로 병원을 찾아 HspWrkNum. + /// 요양기관 행과 같은 조인이라 다중이면 한 행으로 고정한다. + /// + public string IndustrialAccidentNumber(string depCod, string day) + => depCod.Trim().Length == 0 || day.Trim().Length < 8 ? string.Empty + : Scalar( + "SELECT v FROM (SELECT HspWrkNum v FROM M_DepMst" + + " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte" + + " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1", + ("d", depCod.Trim()), ("y", day.Trim()[..8])); + + /// + /// 감염 분류·정보(OCM_감염분류_감염정보, :14084) — 오늘 유효한 감염 기록을 + /// "분류 앞 2자-상세명" 으로 LISTAGG CRLF 결합. 집계라 항상 1행이다. + /// (OCM_감염정보 :14122 는 원문 SQL 의 바인드 콜론이 빠져 항상 오류→빈 값 — + /// 해석기가 사유로 말하고 여기서는 구현하지 않는다.) + /// + public string InfectionInfo(string chtNum) + => Scalar( + "SELECT LISTAGG(SUBSTR(DtlCodNam, 1, 2) || '-' || DtsCodNam, CHR(13) || CHR(10))" + + " WITHIN GROUP (ORDER BY DtsDspSeq)" + + " FROM S_IcnInf" + + " INNER JOIN M_DtlMst ON DtlCod = IcnIcnCod AND DtlTblCod = 'INFECTION'" + + " INNER JOIN M_DtsMst ON DtsCod = IcnDtlCod" + + " WHERE IcnChtNum IN (:c, RPAD(:c, 10))" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN IcnStrDte AND IcnEndDte", + ("c", chtNum)); + + /// + /// 퇴원분석 수술·처치명 목록(OCM_퇴원분석_수술처치명, :13920) — "코드 - 이름" 을 + /// 순번 순으로. 결합은 해석기가 한다(원문은 AppendLine 이라 마지막 뒤에도 개행이 남는다). + /// + public IReadOnlyList> DischargeAnalysisOperations(decimal comNum) + => Rows( + "SELECT DssOpnCod || ' - ' || DssOpnNam AS OpnNam FROM S_DssOpn" + + " WHERE DssComNum = :m" + + " AND (DssOpnCod IS NOT NULL AND DssOpnCod <> ' ')" + + " AND (DssOpnNam IS NOT NULL AND DssOpnNam <> ' ')" + + " ORDER BY DssOpnSeq", + ("m", comNum)); + + /// + /// 병실 인실(OCM_병실인실, :7065) — 입실 이력과 병실 마스터의 배정 침상 수. + /// 원문 Rows(0) 에 ORDER BY 가 없다 — 전실했으면 임의 병실이었다. 최신 입실로 고정한다. + /// + public string RoomBedCount(decimal comNum) + => Scalar( + "SELECT v FROM (SELECT RomAsgBed v FROM P_CowInf" + + " INNER JOIN M_RomMst ON RomCod = CowRomCod" + + " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'" + + " WHERE CowComNum = :m ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1", + ("m", comNum)); + + /// + /// 병실 차액 구분(OCM_병실차액, :7103) — 첫 입실 병실 등급의 건보 산정 구분(SgaHthIns). + /// 원문이 CowStrDtm 오름차순 첫 행이라 결정적이다 — 그대로 옮겼다. + /// + public string RoomExtraCharge(decimal comNum) + => Scalar( + "SELECT v FROM (SELECT SgaHthIns v FROM P_ComInf" + + " INNER JOIN P_CowInf ON CowComNum = ComNum" + + " INNER JOIN M_RomMst ON CowRomCod = RomCod" + + " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'" + + " INNER JOIN M_RgdMst ON CowGrdCod = RgdCod" + + " AND CowStrDtm BETWEEN RgdStrDte AND RgdEndDte || '2359'" + + " INNER JOIN M_SgaMst ON RgdAmtCod = SgaCod" + + " AND CowStrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'" + + " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1", + ("m", comNum)); + /// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙) private string Scalar(string sql, params (string Name, object Value)[] binds) { @@ -233,5 +400,39 @@ public sealed class PatientExtraStore var result = command.ExecuteScalar(); return result is null or DBNull ? string.Empty : result.ToString()!.Trim(); } + + /// 행 하나를 컬럼명(대문자) → 값 사전으로 — 없으면 빈 사전 + private IReadOnlyDictionary Row(string sql, params (string Name, object Value)[] binds) + { + var rows = Rows(sql, binds); + return rows.Count > 0 ? rows[0] : new Dictionary(StringComparer.OrdinalIgnoreCase); + } + + /// 여러 행 — 각 행은 컬럼명 → Trim 값 사전 + private IReadOnlyList> Rows( + string sql, params (string Name, object Value)[] binds) + { + using var connection = new OracleConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = sql; + foreach (var (name, value) in binds) + { + command.Parameters.Add(new OracleParameter(name, value)); + } + 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 65c0acd..0985b79 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2417,6 +2417,62 @@ public static class DbSmoke Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다", consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것"); } + + // ㉟ 부가정보 2차분 — 새 자기 SQL 12종이 예외 없이 돈다. + // O_ 계열 표(O_OkdInf·O_ImlInf·O_OutAdmInf)가 이 시험 DB 에 + // 없으면 개별 [진단] 줄로 남기고 판정은 실행 가능한 것만 본다. + try + { + var probes = new (string Probe, Func Run)[] + { + ("알러지", () => extra.Allergy(extraOwner, null).Length.ToString()), + ("알러지목록", () => extra.AllergyRows(extraOwner).Count.ToString()), + ("보험증번호", () => extra.InsuranceCertNumber(extraOwner).Length.ToString()), + ("재해발생일", () => extra.AccidentDate(extraOwner).Length.ToString()), + ("감염정보", () => extra.InfectionInfo(extraOwner).Length.ToString()), + ("발병일", () => owner2 is null ? "-" : extra.OnsetDate(extraOwner, owner2.Value).Length.ToString()), + ("당일식이", () => owner2 is null ? "-" : extra.TodayMeals(owner2.Value).Count.ToString()), + ("외출예상", () => extra.ExpectedOuting(extraOwner, false).Length.ToString()), + ("퇴원분석", () => owner2 is null ? "-" : extra.DischargeAnalysisOperations(owner2.Value).Count.ToString()), + ("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()), + ("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()), + ("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()), + }; + var shapes2 = new List(); + var failed = new List(); + foreach (var (probe, run) in probes) + { + try + { + shapes2.Add($"{probe} {run()}"); + } + catch (Exception ex) + { + var first = ex.Message.Split('\n')[0]; + // 표 자체가 없는 것(ORA-00942)은 시험 DB 한계다 + if (first.Contains("ORA-00942")) + { + shapes2.Add($"{probe} [표없음]"); + } + else + { + failed.Add($"{probe}: {first}"); + } + } + } + lines.Add($" 부가정보2: {string.Join(" · ", shapes2)}"); + foreach (var f in failed) + { + lines.Add($" [진단] {f}"); + } + Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", failed.Count == 0, + "위 [진단] 줄의 컬럼·조인 이름을 의심할 것"); + } + catch (Exception ex) + { + Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", false, + $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); + } } } catch (Exception ex) diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index b33bfdc..906b5b0 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -114,6 +114,11 @@ public sealed class PatientTagResolver : ITagValueResolver ["OCM_입원내원일시"] = c => Stamp(Com(c, "ComAcpDtm")), ["OCM_입원내원일자"] = c => Hyphenate(Head(Com(c, "ComAcpDtm"), 8)), + // 전과 일시·일자 — 문맥 진료 행의 시작일시(bzDataInterface.vb:12985/12990). + // 레거시는 행 유무 검사 없이 Substring 해 없으면 예외→노란색이다 — 여기는 빈 값. + ["OCM_전과일시"] = c => Stamp(PatientContext.Value(c.CodInf, "CodStrDtm")), + ["OCM_전과일자"] = c => Hyphenate(Head(PatientContext.Value(c.CodInf, "CodStrDtm"), 8)), + // 내원유형 — 입원/응급실/외래(bzDataInterface.vb:2896-2918). // 응급 여부는 내원행의 ComEmgYon 이다. 그 밖의 환자구분은 레거시도 "" 다. ["OCM_내원유형"] = c => Com(c, "ComPatTyp") switch @@ -585,6 +590,9 @@ public sealed class PatientTagResolver : ITagValueResolver return DeptColumn("DepOfcNam", "진료과 마스터에 대외 명칭이 비어 있습니다"); case "OCM_진료과약어명칭": return DeptColumn("DepBrfNam", "진료과 마스터에 약어 명칭이 비어 있습니다"); + case "OCM_진료과_전화번호": + // 같은 행의 전화번호 컬럼(:2773-2822) — 시점 판정도 파생 4종과 동일 + return DeptColumn("DepTelNum", "진료과 마스터에 전화번호가 비어 있습니다"); // ── 요양기관 12종 — 같은 조인의 다른 컬럼(bzDataInterface.vb:15656-16709) ── // 접미·결합도 본문 그대로: 병원장 = 병원명+"장", 귀하 = +"장 귀하", // 전화_팩스 = "Tel : … FAX : …", 영문판은 "+82 - " 접두가 붙는다. @@ -965,7 +973,8 @@ public sealed class PatientTagResolver : ITagValueResolver 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_비만도")) + or "OCM_표준체중_LAST" or "OCM_조정체중_LAST" or "OCM_비만도" + or "OCM_VITAL접수일시_LAST")) { return null; } @@ -996,6 +1005,22 @@ public sealed class PatientTagResolver : ITagValueResolver ? new TagValue(true, bst) : new TagValue(false, "이 내원에 BST 기록(E_EmdInf_BST)이 없습니다"); } + case "OCM_VITAL접수일시_LAST": + { + // 가장 늦은 접수 '시각'(EmrAdpTim, 시분 4자리)을 HH:MM 으로(:12996-13028). + // 정렬 키가 시각뿐이라 날짜와 무관하게 하루 중 가장 늦은 시각이 나온다 — + // 원문이 그렇다(SQL 의 SUBSTR 은 1-기반). + if (vitalSource is null) + { + return new TagValue(false, "바이탈 조회가 연결되지 않았습니다"); + } + var time = vitalSource( + "SUBSTR(EmrAdpTim, 1, 2) || ':' || SUBSTR(EmrAdpTim, 3, 2) AS ADPTIM", + "ADPTIM", new[] { "EmrAdpTim" }, "EmrAdpTim DESC"); + return time.Length > 0 + ? new TagValue(true, time) + : new TagValue(false, "이 내원에 접수 시각이 있는 바이탈 기록이 없습니다"); + } case "OCM_비만도": { // 내원 기준(EmrComNum) — 키·체중을 따로 최신으로 집는다(:12333-12420) @@ -1339,18 +1364,32 @@ public sealed class PatientTagResolver : ITagValueResolver return new TagValue(true, ((end - start).Days + 1).ToString()); } + /// 가 담당하는 태그들 — 전부 자기 SQL 이거나 그 조합 + private static readonly HashSet ExtraTags = new(StringComparer.Ordinal) + { + "PAT_실제생년월일", "PAT_국적", "PAT_장애등급", "PAT_건보세대주명", + "PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처", + "OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일", + "OCM_최초내원일", "OCM_초진일_발병일", "OCM_신환_초진일", + "OCM_입원일자_낮병동", "OCM_혈액형", "OCM_LMP", + "OCM_발병일", "OCM_당일식이", + "OCM_알러지", "OCM_알러지_조영제", "OCM_알러지_약물", "OCM_알러지_음식", "OCM_알러지_기타", + "OCM_알러지_List", + "OCM_외출예상시작일시", "OCM_외출예상종료일시", + "ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호", + "OCM_감염분류_감염정보", "OCM_감염정보", + "OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명", + "OCM_병실인실", "OCM_병실차액", + }; + /// - /// 환자 부가정보 7종 — 태그별 자기 SQL(). + /// 환자 부가정보 — 태그별 자기 SQL(). /// 전부 지연 + 태그별 캐시다: 이 태그가 없는 서식이 대부분이라 미리 당기지 않고, /// 같은 태그가 한 장에 여러 번 있어도 왕복은 한 번이다. /// private TagValue? FromExtra(string tag) { - if (tag is not ("PAT_실제생년월일" or "PAT_국적" or "PAT_장애등급" or "PAT_건보세대주명" - 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_LMP")) + if (!ExtraTags.Contains(tag)) { return null; } @@ -1449,6 +1488,156 @@ public sealed class PatientTagResolver : ITagValueResolver } case "OCM_혈액형": return Wrap(store.BloodType(chtNum), "혈액형 판정(S_BlpInf) 행이 없습니다"); + case "OCM_발병일": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + if (!decimal.TryParse(comNum, out var m)) + { + return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + var onset = store.OnsetDate(chtNum, m); + return onset.Length >= 8 + ? new TagValue(true, Hyphenate(onset[..8])) + : new TagValue(false, "주상병과 짝이 되는 발병 기록(O_OnsInf)이 없습니다"); + } + case "OCM_당일식이": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + if (!decimal.TryParse(comNum, out var m)) + { + return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + // 끼니별로 "아침 : X " 를 이어붙인다 — 뒤 공백 1칸까지 원문 그대로(:12646-12656) + var meals = store.TodayMeals(m); + if (meals.Count == 0) + { + return new TagValue(false, "오늘 등록된 입원 식이(O_ImlInf)가 없습니다"); + } + var text = string.Empty; + foreach (var (label, column) in new[] + { ("아침", "아침_MGDTYP"), ("점심", "점심_MGDTYP"), ("저녁", "저녁_MGDTYP") }) + { + var meal = PatientContext.Value(meals, column); + if (meal.Length > 0) + { + text += $"{label} : {meal} "; + } + } + return text.Length > 0 + ? new TagValue(true, text) + : new TagValue(false, "오늘 식이 행은 있는데 세 끼니가 전부 비어 있습니다"); + } + case "OCM_알러지": + case "OCM_알러지_조영제": + case "OCM_알러지_약물": + case "OCM_알러지_음식": + case "OCM_알러지_기타": + { + // 카테고리 리터럴은 레거시가 한글로 하드코딩했다(:15018 등) + var category = tag switch + { + "OCM_알러지_조영제" => "조영제", + "OCM_알러지_약물" => "약물", + "OCM_알러지_음식" => "음식", + "OCM_알러지_기타" => "기타", + _ => (string?)null, + }; + return Wrap(store.Allergy(chtNum, category), + "코드마스터(ALLERGY)에 매칭되는 알러지 기록이 없습니다"); + } + case "OCM_알러지_List": + { + var rows = store.AllergyRows(chtNum); + if (rows.Count == 0) + { + return new TagValue(false, "알러지 기록(S_AlgInf)이 없습니다"); + } + // 원문 결합(:15160-15178): "(분류) 종류[ - 증상]" 을 CRLF 로. + // 구분자 " - " 는 한 번 켜지면 꺼지지 않는다(레거시 스티키 결함) — + // 뒤 행의 증상이 비어도 " - " 가 붙는다. 그대로 옮겼다. + var separator = string.Empty; + var list = new System.Text.StringBuilder(); + foreach (var row in rows) + { + var kind = PatientContext.Value(row, "AlgAlgTyp"); + var symptom = PatientContext.Value(row, "AlgAlgSpm"); + if (kind.Length > 0 && symptom.Length > 0) + { + separator = " - "; + } + if (list.Length > 0) + { + list.Append("\r\n"); + } + list.Append($"({PatientContext.Value(row, "AlgAlgCtg")}) {kind}{separator}{symptom}"); + } + return new TagValue(true, list.ToString()); + } + case "OCM_외출예상시작일시": + case "OCM_외출예상종료일시": + return Wrap(store.ExpectedOuting(chtNum, tag == "OCM_외출예상종료일시"), + "오늘 이후 시작하는 외출·외박 신청(O_OutAdmInf)이 없습니다"); + case "ETC_건강보험증번호": + return Wrap(store.InsuranceCertNumber(chtNum), "건보 자격(P_PisInf) 행이 없습니다"); + case "ETC_재해발생일": + return Wrap(store.AccidentDate(chtNum), "산재 자격(P_PisInf, PisInsCod=4) 행이 없습니다"); + case "ETC_산재지정번호": + { + // 진료과·시점은 문맥의 것 — 레거시 시점 분기(퇴원/현재/접수)와 같은 판정이다 + var depCod = PatientContext.Value(context.CodInf, "CodDepCod"); + if (depCod.Length == 0) + { + return new TagValue(false, "이 내원의 진료과 코드를 읽지 못했습니다"); + } + return Wrap(store.IndustrialAccidentNumber(depCod, context.AdpDtm), + "요양기관 행에서 산재지정번호(HspWrkNum)를 찾지 못했습니다"); + } + case "OCM_감염분류_감염정보": + return Wrap(store.InfectionInfo(chtNum), "오늘 유효한 감염 기록(S_IcnInf)이 없습니다"); + case "OCM_감염정보": + // 원문 SQL(:14146)의 차트번호 바인드에 콜론이 빠져 있어(IcnChtNum = sChtNum) + // 실행마다 ORA-00904 → 오류창 → 빈 값이다 — 고치면 없던 값이 갑자기 생긴다 + return new TagValue(false, + "레거시 결함(바인드 콜론 누락)으로 항상 빈 값인 태그입니다 — OCM_감염분류_감염정보를 쓰세요"); + case "OCM_퇴원분석_수술처치명": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + if (!decimal.TryParse(comNum, out var m)) + { + return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + var operations = store.DischargeAnalysisOperations(m); + if (operations.Count == 0) + { + return new TagValue(false, "퇴원분석 수술·처치(S_DssOpn) 행이 없습니다"); + } + // 원문은 AppendLine 결합이라 마지막 줄 뒤에도 개행이 남는다(:13950) — 그대로 + var text = new System.Text.StringBuilder(); + foreach (var row in operations) + { + text.Append(PatientContext.Value(row, "OpnNam")).Append("\r\n"); + } + return new TagValue(true, text.ToString()); + } + case "OCM_Cosign_의사명": + // 레거시는 저장된 기록 키(moShtInfoBiz.EmrKey)로 확인자(E_RcdInf)를 찾는다(:14834). + // 미리보기는 저장된 기록이 아니라 그 키가 없다 — _Refer 계열과 같은 이유로 완결. + return new TagValue(false, + "저장된 기록에서만 값이 됩니다 — 미리보기에는 기록 키(EmrKey)가 없습니다"); + case "OCM_병실인실": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + return decimal.TryParse(comNum, out var m) + ? Wrap(store.RoomBedCount(m), "입실 이력과 병실 마스터가 짝이 되는 행이 없습니다") + : new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + case "OCM_병실차액": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + return decimal.TryParse(comNum, out var m) + ? Wrap(store.RoomExtraCharge(m), "병실 등급·산정 마스터가 짝이 되는 행이 없습니다") + : new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } case "OCM_LMP": { var comNum = PatientContext.Value(context.ComInf, "ComNum"); @@ -1619,6 +1808,12 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_입원의사", "OCM_퇴원의사", "OCM_협진과", "OCM_협진과_협진의", "OCM_혈액형", "OCM_LMP", "OCM_BST", "OCM_BST_LAST", "OCM_BMI", "OCM_표준체중_LAST", "OCM_조정체중_LAST", "OCM_비만도", + "OCM_VITAL접수일시_LAST", "OCM_발병일", "OCM_당일식이", + "OCM_알러지", "OCM_알러지_조영제", "OCM_알러지_약물", "OCM_알러지_음식", "OCM_알러지_기타", + "OCM_알러지_List", "OCM_외출예상시작일시", "OCM_외출예상종료일시", + "ETC_건강보험증번호", "ETC_재해발생일", "ETC_산재지정번호", + "OCM_감염분류_감염정보", "OCM_감염정보", "OCM_퇴원분석_수술처치명", "OCM_Cosign_의사명", + "OCM_병실인실", "OCM_병실차액", "OCM_진료과_전화번호", "OCM_전과일시", "OCM_전과일자", "OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간", "OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시", "OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",