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); } /// /// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전. /// 컬럼명은 대문자로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면 /// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다. /// 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자리 public IReadOnlyDictionary Doctor(decimal comNum, string moment, string? doctorDay = null) { 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, "CodDtrCod"); 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(); return Row(connection, "SELECT * FROM (SELECT HspInsNam, HspOwnNam, HspTelNum, HspFaxNum," + " HspKorAdr, HspEngNam, HspEngAdr, HspRgnNum, HspInsNum 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])); } /// /// 건물관리번호로 주소 — 레거시 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 }