using Oracle.ManagedDataAccess.Client; namespace SheetMe.Data.Stores; /// /// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다. /// 각 행은 컬럼명 → 값 사전이다. 태그가 <<Patient.PatInfDR.item("PatNam")>> 처럼 /// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다. /// public sealed record PatientContext( decimal ComNum, string AdpDtm, IReadOnlyDictionary PatInf, IReadOnlyDictionary ComInf, IReadOnlyDictionary CodInf, IReadOnlyDictionary CoiInf, IReadOnlyDictionary CowInf) { /// 행 하나에서 컬럼 값 — 없으면 빈 문자열 public static string Value(IReadOnlyDictionary row, string column) => row.TryGetValue(column, out var value) ? value : string.Empty; } /// /// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 bzPatientInfo.SetPatientInfo 대응. /// /// 다섯 행이 전부 적용일시를 키로 쓴다. 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf), /// 병동·병실(P_CowInf) 은 :AdpDtm BETWEEN StrDtm AND EndDtm 이다 — /// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다. /// 그래서 시각 판정은 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다. /// /// 레거시와 다른 점: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다 /// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 한 번에 다섯 번 읽고 들고 있는다 — /// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다. /// /// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다. /// public sealed class PatientContextStore { #region Member Fields private readonly string connectionString; #endregion #region Constructors public PatientContextStore(string connectionString) => this.connectionString = connectionString; #endregion #region Methods /// 이 내원의 문맥 — 내원 자체가 없으면 null public PatientContext? Load(decimal comNum, Core.Catalog.VisitEdge edge = Core.Catalog.VisitEdge.Last) { using var connection = new OracleConnection(connectionString); connection.Open(); var comInf = Row(connection, "SELECT * FROM P_ComInf WHERE ComNum = :k", ("k", comNum)); if (comInf.Count == 0) { return null; } var window = new Core.Catalog.VisitWindow( PatientContext.Value(comInf, "COMPATTYP") == "I", PatientContext.Value(comInf, "COMACPDTM"), PatientContext.Value(comInf, "COMLEVDTM"), string.Empty, string.Empty); // 자격 구간은 P_CoiInf 가 갖고 있다 — 시각을 정하려면 먼저 읽어야 하고, // 그 읽기 자체는 시각을 쓰지 않는다(MtiSeq 0 의 최신 행). var coiSeed = Row(connection, "SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0" + " ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum)); window = window with { CoverageFrom = PatientContext.Value(coiSeed, "COISTRDTE"), CoverageTo = PatientContext.Value(coiSeed, "COIENDDTE"), }; var adpDtm = Core.Catalog.VisitMoment.Decide(window, edge, ServerClock.Read(connection, null).Minute12); var chtNum = PatientContext.Value(comInf, "COMCHTNUM"); var patInf = Row(connection, "SELECT * FROM P_PatInf WHERE TRIM(PatChtNum) = :c", ("c", chtNum.Trim())); var codInf = Row(connection, "SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0" + " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1", ("k", comNum), ("t", adpDtm)); var coiInf = Row(connection, "SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0" + " AND :d BETWEEN CoiStrDte AND CoiEndDte ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum), ("d", Day(adpDtm))); var cowInf = Row(connection, "SELECT * FROM (SELECT * FROM P_CowInf WHERE CowComNum = :k" + " AND :t BETWEEN CowStrDtm AND CowEndDtm ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1", ("k", comNum), ("t", adpDtm)); return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf); } /// /// 특정 시점의 진료과 코드 — 레거시 GetCodInfDT(comNum, moment, 1) 의 첫 행 대응. /// 입원과(접수 시점)·퇴원과(퇴원/현재 시점) 태그가 문맥의 적용일시와 다른 시점을 /// 물을 때 쓴다. 원문 GetCodInfDT 는 ORDER BY 가 없어 임의 행이었다 — 문맥 로드와 /// 같은 규칙(시작일시 최신 1행)으로 고정한다. moment 가 비면 서버 현재시각이다. /// public string DepartmentCodeAt(decimal comNum, string? moment) => PatientContext.Value(CodInfAt(comNum, moment), "CodDepCod"); /// 특정 시점의 진료 행(진료과 코드 + 담당의 코드) — 의 원형 public IReadOnlyDictionary CodInfAt(decimal comNum, string? moment) { using var connection = new OracleConnection(connectionString); connection.Open(); var at = (moment ?? string.Empty).Trim(); return Row(connection, "SELECT CodDepCod, CodDtrCod FROM (SELECT CodDepCod, CodDtrCod FROM P_CodInf" + " WHERE CodComNum = :k AND CodMtiSeq = 0" + (at.Length >= 12 ? " AND :t BETWEEN CodStrDtm AND CodEndDtm" : " AND TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI') BETWEEN CodStrDtm AND CodEndDtm") + " ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1", at.Length >= 12 ? new (string, object)[] { ("k", comNum), ("t", at[..12]) } : new (string, object)[] { ("k", comNum) }); } /// /// 특정 일자에 유효한 사용자 이름(M_UidMst.UidNam) — 입원의사·퇴원의사가 쓴다 /// (bzDataInterface.vb:6520-6527). 유효기간이 겹치는 이중 행은 정상 데이터가 아니지만 /// 원문의 Rows(0) 대신 한 행으로 고정한다. /// public string UserNameAt(string uidCod, string day) { if (uidCod.Trim().Length == 0 || day.Trim().Length < 8) { return string.Empty; } using var connection = new OracleConnection(connectionString); connection.Open(); var row = Row(connection, "SELECT UidNam FROM (SELECT UidNam FROM M_UidMst WHERE UidCod = :u" + " AND :d BETWEEN UidStrDte AND UidEndDte ORDER BY UidStrDte DESC) WHERE ROWNUM = 1", ("u", uidCod.Trim()), ("d", day.Trim()[..8])); return PatientContext.Value(row, "UidNam").Trim(); } /// /// 협진 목록(bzDataInterface.vb:6360-6494) — 회신 완료(CstSttFlg='G')된 협진의 /// 회신 과(+의사). 원문 그대로: DISTINCT + 시작일 순 + 앞에서 과는 2건, 과·의사는 3건. /// public IReadOnlyList> Consults(decimal comNum, bool withDoctor) { using var connection = new OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = withDoctor ? "SELECT * FROM (SELECT DISTINCT UidCod, UidNam, DepCod, DepKorNam, CstStrDte FROM O_CstInf" + " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte" + " INNER JOIN M_UidMst ON CstRplDtr = UidCod AND CstStrDte BETWEEN UidStrDte AND UidEndDte" + " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 3" : "SELECT * FROM (SELECT DISTINCT DepCod, DepKorNam, CstStrDte FROM O_CstInf" + " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte" + " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 2"; command.Parameters.Add(new OracleParameter("m", comNum)); 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; } /// /// 퇴원과 조회(bzDataInterface.vb:6162-6231) — 내원의 퇴원일시(또는 접수일시)가 /// 유효기간 안인 진료과 행. 원문이 P_ComInf 에 M_DepMst 를 날짜 조건만으로 조인하는 /// 특이한 모양이라 그대로 옮겼다. anchorColumn 은 ComLevDtm 또는 ComAcpDtm. /// public IReadOnlyDictionary DischargeDepartment(decimal comNum, string depCod, string anchorColumn) { if (anchorColumn is not ("ComLevDtm" or "ComAcpDtm")) { throw new ArgumentOutOfRangeException(nameof(anchorColumn)); } using var connection = new OracleConnection(connectionString); connection.Open(); return Row(connection, "SELECT * FROM (SELECT DepOfcNam, DepKorNam FROM P_ComInf" + " INNER JOIN M_DepMst ON DepCod = :d" + $" AND {anchorColumn} BETWEEN DepStrDte AND DepEndDte || '2359'" + " WHERE ComNum = :k) WHERE ROWNUM <= 1", ("d", depCod), ("k", comNum)); } /// /// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전. /// 컬럼명은 대문자로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면 /// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다. /// private static Dictionary Row(OracleConnection connection, string sql, params (string Name, object Value)[] parameters) { var result = new Dictionary(StringComparer.OrdinalIgnoreCase); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = sql; foreach (var (name, value) in parameters) { command.Parameters.Add(new OracleParameter(name, value)); } using var reader = command.ExecuteReader(); if (!reader.Read()) { return result; } for (var i = 0; i < reader.FieldCount; i++) { // CHAR 고정폭 잔여 공백을 여기서 한 번 걷는다 — 태그마다 걷으면 빠뜨린다 result[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim(); } return result; } private static string Day(string stamp) => stamp.Length >= 8 ? stamp[..8] : stamp; /// /// 이 내원의 담당의 — OCM_담당의사 계열이 쓴다. 없으면 빈 사전. /// /// 레거시와 의도적으로 다른 곳이 한 군데 있다. /// 원본 GetCodInfDT(..., With_Join)CodComNum + CodMtiSeq='0' 으로만 거르고 /// ORDER BY 가 없는데 호출부는 Rows(0) 을 집는다. /// 이 DB 에서 세어 보니 그런 내원이 25,488건이고 최대 41행이다 — /// 즉 레거시는 그 내원들의 담당의를 확정적으로 찍지 못한다. 재현할 대상이 없으므로 /// "그대로 옮긴다"가 성립하지 않는다. /// /// 그래서 적용일시로 거르고 최신순 1행을 집는다 — 문맥의 CodInf 와 같은 규칙이다. /// 레거시도 이 시각을 넘기기는 한다(마스터 유효기간 조인에만 쓸 뿐이다). /// 근거는 docs/TAG-PORTABLE-48.md. /// /// 담당의 조회는 레거시 GetUidMst(clsCommonLib.vb:10615)를 그대로 옮긴다 — /// 유효기간으로 먼저 찾고, 0행이면 코드만으로 다시 찾는다. /// 그 폴백을 빼면 유효기간이 지난 의사가 통째로 빈칸이 된다. /// /// 진료과 행을 고를 적용일시 12자리 /// 담당의 유효기간을 볼 날짜 8자리. 비면 의 앞 8자리 /// 진료과 행에서 의사 코드를 집을 컬럼 — 담당의 CodDtrCod, 주치의(전공의) CodStfDtr public IReadOnlyDictionary Doctor(decimal comNum, string moment, string? doctorDay = null, string codeColumn = "CodDtrCod") { using var connection = new OracleConnection(connectionString); connection.Open(); var cod = Row(connection, "SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0" + " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1", ("k", comNum), ("t", moment)); var code = PatientContext.Value(cod, codeColumn); if (code.Length == 0) { return cod; } // 레거시는 입원이면 CodEndDtm 의 날짜로, 외래면 접수일자로 본다 — 호출부가 정해 넘긴다 var day = Day((doctorDay ?? string.Empty).Trim().Length > 0 ? doctorDay!.Trim() : moment); var uid = Row(connection, "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c" + " AND :d BETWEEN UidStrDte AND UidEndDte) WHERE ROWNUM = 1", ("c", code), ("d", day)); return uid.Count > 0 ? uid // 폴백 — 유효기간 밖이어도 그 코드의 행을 쓴다(레거시 :10641-10644) : Row(connection, "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c) WHERE ROWNUM = 1", ("c", code)); } /// /// 진료과 마스터 한 행 — OCM_진료과(bzDataInterface.vb:5437-5495)의 조회. /// WHERE DepCod = :d AND :day BETWEEN DepStrDte AND DepEndDte. /// /// 유효기간 판정 일자는 레거시가 입원·외래 무관하게 접수일자를 쓴다(:5445 sAcpDte). /// 원본에는 ORDER BY 가 없고 Rows(0) 을 집는다 — 담당의 때와 같은 이유로 /// 최신순 1행으로 고정한다(기간이 겹친 이력에서 비결정적이 되는 것을 막는다). /// public IReadOnlyDictionary Department(string? depCod, string? day) { var code = (depCod ?? string.Empty).Trim(); var date = (day ?? string.Empty).Trim(); if (code.Length == 0 || date.Length < 8) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } using var connection = new OracleConnection(connectionString); connection.Open(); return Row(connection, "SELECT * FROM (SELECT * FROM M_DepMst WHERE TRIM(DepCod) = :d" + " AND :y BETWEEN DepStrDte AND DepEndDte ORDER BY DepStrDte DESC) WHERE ROWNUM = 1", ("d", code), ("y", date[..8])); } /// /// 요양기관 행 — ETC_요양기관명칭_* 12종(bzDataInterface.vb:15656-16709)의 공통 조회. /// M_DepMst ⨝ M_HspMst(DepHspCod=HspCod) 에서 병원 컬럼들을 뽑는다. /// 진료과 코드로 병원을 찾는 구조다 — 다부지(병원 코드가 다른 진료과) 구성 때문으로 보인다. /// /// 레거시는 태그마다 이 조인을 따로 돌린다(12종 = 왕복 12회). /// 컬럼만 다르므로 한 번에 다 뽑아 행으로 돌려준다 — 값 선택은 해석기가 한다. /// 컬럼 9개는 전부 각 태그 본문의 SELECT 에서 확인한 것이다. /// public IReadOnlyDictionary HospitalRow(string? depCod, string? day) { var code = (depCod ?? string.Empty).Trim(); var date = (day ?? string.Empty).Trim(); if (code.Length == 0 || date.Length < 8) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } using var connection = new OracleConnection(connectionString); connection.Open(); // HspStrDte(개원일)는 퇴원 계열 태그의 분기 재료(HspCfg.HspStrDte 대응), // ServerToday 는 "오늘" 폴백·재원일수 계산용 서버 날짜 — 단말 시계를 쓰지 않는다 return Row(connection, "SELECT * FROM (SELECT HspInsNam, HspOwnNam, HspTelNum, HspFaxNum," + " HspKorAdr, HspEngNam, HspEngAdr, HspRgnNum, HspInsNum, HspStrDte," + " TO_CHAR(SYSDATE, 'YYYYMMDD') AS ServerToday 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", 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 — /// 무인자(Else 갈래)는 ASC(첫 수술), ETC_ 계열과 마지막수술은 DESC('수술실 요청' 주석 그대로). /// GetOprInfDT(E_OprInf 축 큰 조회)와 다른 표다 — 이쪽은 수술 접수 일정이다. /// public IReadOnlyDictionary ScheduledSurgery(decimal comNum, bool latestFirst) { using var connection = new OracleConnection(connectionString); connection.Open(); return Row(connection, "SELECT * FROM (SELECT * FROM S_OprInf WHERE OprComNum = :c AND OprStt = 'E'" + $" ORDER BY OprKey{(latestFirst ? " DESC" : string.Empty)}) WHERE ROWNUM = 1", ("c", comNum)); } /// /// 건물관리번호로 주소 — 레거시 GetZipInfoByBuildingNumber(clsCommonLib.vb:16600). /// /// 결합식을 그대로 옮긴다. DECODE 로 빈 조각을 건너뛰고 구분자를 붙이는 규칙이 /// 도로명·지번·영문이 각각 다르다 — 손으로 다시 짜면 주소가 미묘하게 달라진다. /// /// 없으면 빈 사전. 레거시도 못 찾으면 Nothing 을 돌려주고 호출부가 상세주소만 쓴다. /// public IReadOnlyDictionary Address(string? buildingNumber) { var key = (buildingNumber ?? string.Empty).Trim(); if (key.Length == 0) { return new Dictionary(StringComparer.OrdinalIgnoreCase); } using var connection = new OracleConnection(connectionString); connection.Open(); return Row(connection, "SELECT 구역번호, 우편번호" + ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')" + " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')" + " || DECODE(TRIM(도로명), NULL, '', TRIM(도로명) || ' ')" + " || DECODE(지하여부, 1, '지하 ', '')" + " || DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번))" + " || DECODE(TRIM(건물번호부번), NULL, ' ', 0, ' ', '-' || TRIM(건물번호부번) || ' ')" + " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 도로명주소" + ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')" + " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')" + " || DECODE(TRIM(법정동명), NULL, '', TRIM(법정동명) || ' ')" + " || DECODE(TRIM(리명), NULL, '', TRIM(리명) || ' ')" + " || DECODE(산여부, 1, ' 산 ', '')" + " || DECODE(TRIM(지번본번), NULL, '', TRIM(지번본번))" + " || DECODE(TRIM(지번부번), NULL, ' ', 0, ' ', '-' || TRIM(지번부번) || ' ')" + " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 지번주소" + ", DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번) || ' ')" + " || DECODE(TRIM(도로명영문), NULL, '', TRIM(도로명영문) || ', ')" + " || DECODE(TRIM(읍면영문), NULL, '', TRIM(읍면영문) || ', ')" + " || DECODE(TRIM(시군구영문), NULL, '', TRIM(시군구영문) || ', ')" + " || TRIM(시도영문) AS 영문도로명주소" + " FROM M_ZipMst WHERE 건물관리번호 = :b", ("b", key)); } #endregion }