diff --git a/src/SheetMe.Data/Stores/SurgeryStore.cs b/src/SheetMe.Data/Stores/SurgeryStore.cs new file mode 100644 index 0000000..cff38b6 --- /dev/null +++ b/src/SheetMe.Data/Stores/SurgeryStore.cs @@ -0,0 +1,192 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// 수술 정보 조회 — 레거시 GetOprInfDT(bzDataInterface.vb:13203-13363)의 이식. +/// +/// 상병과 달리 병원 분기가 없다(Select Case 0회). 바인드는 내원번호 하나뿐이고 +/// 접수일자 조건은 원문에서 이미 주석 처리되어 있다(:sAcpDte — 죽은 코드). +/// +/// SQL 은 원문에서 기계 추출했다(133줄 그대로). O_OprInf 를 축으로 수술명·부위·상병· +/// 집도의·마취의를 LISTAGG 로 접는 큰 조회이고, 소비 태그 8종이 전부 +/// 첫 행에서 컬럼 하나를 집는다 — 그래서 행 목록만 돌려주고 선택은 해석기가 한다. +/// +/// 원문의 Catch 는 ErrorMessageBox 를 띄운다 — 여기서는 던진다(해석기가 사유로 바꾼다). +/// 조회 함수가 대화상자를 띄우는 부작용은 옮기지 않는다. +/// +public sealed class SurgeryStore +{ + #region Member Fields + private readonly string connectionString; + + /// 원문 그대로의 조회 — 바인드는 :iComNum 하나다 + private const string Query = @" +With + OprInfRec + AS + ( + SELECT Opr.* + FROM ( + + SELECT /* 수술 정보 */ + OprKey + , OprDte + , OprChtNum + , OprComNum, OprOrgCom, OprGbnCod, OprStt + , OprRomCod + , OprUseTms, OprStrTms, OprDayNum + , OprActStt + , OprNam + , OprDtrCod, OprDepCod, OprSplCmt + , CASE OprRegion WHEN '1' THEN '오른쪽' + WHEN '2' THEN '왼쪽' + WHEN '3' THEN '양쪽' + ELSE '없음' + END AS OprRegionName + , OprPatEtc + , OPR.OprUpdDtf + + /* 인적 정보 */ + , PatNam + , PatSexTyp + , ComPatTyp + , SUBSTR(ComAcpDtm, 1, 8) AS ComAcpDte + + /* 관련 정보 */ + , PRGSTSDTL.DtlCodNam AS OprActFlg + , UIDOPRDTR.UidNam AS OprDtrNam /* 집도의 명 */ + , UIDCODDTR.UidNam AS CodDtrNam /* 주치의 명 */ + + , ROW_NUMBER() OVER (PARTITION BY OprKey ORDER BY CodStrDtm DESC ) AS RN + + FROM S_OprInf OPR + LEFT JOIN P_PatInf + ON PatChtNum = OprChtNum + + /* 수술정보의 내원정보 */ + LEFT JOIN P_ComInf + ON OprComNum = ComNum + LEFT JOIN P_CodInf + ON CodComNum = ComNum + AND CASE WHEN ComPatTyp = 'I' THEN OprDte ||'2359' + ELSE CodStrDtm + END BETWEEN CodStrDtm AND CodEndDtm + + LEFT JOIN M_DtlMst PRGSTSDTL + ON PRGSTSDTL.DtlTblCod = 'OPRPRGSTS' + AND PRGSTSDTL.DtlCod = OprActStt + /* 집도의 */ + LEFT JOIN M_UidMst UIDOPRDTR + ON UIDOPRDTR.UidCod = OprDtrCod + AND OprDte BETWEEN UIDOPRDTR.UidStrDte AND UIDOPRDTR.UidEndDte + /* 주치의 */ + LEFT JOIN M_UidMst UIDCODDTR + ON UIDCODDTR.UidCod = CodDtrCod + AND OprDte BETWEEN UIDCODDTR.UidStrDte AND UIDCODDTR.UidEndDte + + WHERE 1 = 1 + AND OprStt = 'E' +-- AND OprDte = :sAcpDte + AND OprComNum = :iComNum +-- AND OprDepCod IN ('0100','0101','0200','0201','0300','1601','1900','2100','2301','7001','0400','0500','0501','0550','0600','0601','0900','0901','1000','1100','1300','2601','0800','1400','1500','1600','5500') + + ) OPR + + WHERE RN = 1 + ) + , + OprTypRec + AS + ( + SELECT OprTypKey, DX, OPNAME + , SUBSTR(ANECOD, 1, LENGTH(ANECOD)-1) AS ANECOD + , SUBSTR(ANECODNAM, 1, LENGTH(ANECODNAM)-1) AS ANECODNAM + , SUBSTR(ANEUIDCOD, 1, LENGTH(ANEUIDCOD)-1) AS ANEUIDCOD + , SUBSTR(OKDNAM, 1, LENGTH(OKDNAM)) AS OKDNAM + , SUBSTR(OPRCODNAM, 1, LENGTH(OPRCODNAM)) AS OPRCODNAM + FROM ( + SELECT OprKey AS OprTypKey + , MAX(DX.OprCodNam) AS DX + , MAX(OPNAME.OprCodNam) AS OPNAME + , regexp_replace(LISTAGG(ANECOD.OprCod || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECOD + , regexp_replace(LISTAGG(ANECOD.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECODNAM + , regexp_replace(LISTAGG(ANEUID.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANEUID.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANEUIDCOD + , MIN(OKDNAM.OprCodNam) AS OKDNAM + , MIN(OPRCODNAM.OprCodNam) AS OPRCODNAM + + FROM OprInfRec + LEFT JOIN S_OprTyp DX + ON OprKey = DX.OprKeyNum + AND DX.OprTyp = 'DX' + LEFT JOIN S_OprTyp OPNAME + ON OprKey = OPNAME.OprKeyNum + AND OPNAME.OprTyp = 'OPNAME' + LEFT JOIN S_OprTyp ANECOD + ON OprKey = ANECOD.OprKeyNum + AND ANECOD.OprTyp = 'ANECOD' + LEFT JOIN S_OprTyp ANEUID + ON OprKey = ANEUID.OprKeyNum + AND ANEUID.OprTyp = 'ANEUIDCOD' + LEFT JOIN S_OprTyp OKDNAM + ON OprKey = OKDNAM.OprKeyNum + AND OKDNAM.OprTyp = 'OKD' + LEFT JOIN S_OprTyp OPRCODNAM + ON OprKey = OPRCODNAM.OprKeyNum + AND OPRCODNAM.OprTyp = 'OPRCOD' + AND ROWNUM = 1 + + WHERE 1 = 1 + GROUP BY OprKey + ) + + ) + , + OprLstRec + AS + ( + SELECT * + FROM OprInfRec + LEFT JOIN OprTypRec + ON OprKey = OprTypKey + ) + +--SELECT * FROM OprInfRec +--SELECT * FROM OprTypRec +SELECT * FROM OprLstRec +ORDER BY OprComNum, OprDte DESC, OprDayNum ASC +"; + #endregion + + #region Constructors + public SurgeryStore(string connectionString) => this.connectionString = connectionString; + #endregion + + #region Methods + /// 이 내원의 수술 행들 — 없으면 빈 목록 + public List> Surgeries(decimal comNum) + { + using var connection = new OracleConnection(connectionString); + connection.Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = Query; + command.Parameters.Add(new OracleParameter("iComNum", 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; + } + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index b889bc3..a0f8919 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2237,6 +2237,32 @@ public static class DbSmoke "M_KcdMst 조인이 빈 이름을 냈다"); } } + + // ㉘ 수술 — 원문 기계 추출 SQL 이 실제로 돈다. + var oprOwner = FirstComNum(conn, "O_OprInf", "OprComNum", "OprComNum"); + if (oprOwner is null) + { + // "행이 없다"와 "표가 없다/조회가 죽었다"를 가른다 — + // FirstComNum 은 예외를 삼키므로 이 줄 없이는 못 가른다. + foreach (var line in ExplainPeriod(conn, 0, + "SELECT COUNT(*) OPRCNT FROM O_OprInf WHERE :k = 0")) + { + lines.Add($" [진단] O_OprInf: {line}"); + } + lines.Add("SKIP ㉘ 수술 — 확인 불가. 위 [진단] 줄이 원인이다:" + + " ORA-00942 면 이 시험 DB 에 O_OprInf 가 아예 없는 것이고," + + " 수술 태그는 운영 DB 에서만 검증할 수 있다"); + } + else + { + var surgeries = new SurgeryStore(conn).Surgeries(oprOwner.Value); + var opName = surgeries.Count > 0 + && surgeries[0].TryGetValue("OPNAME", out var on) ? on : ""; + lines.Add($" 수술: 내원 {oprOwner.Value:F0} → {surgeries.Count}행," + + $" OPNAME {opName.Length}자 (값은 기록하지 않는다)"); + Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0, + "OprStt='E' 조건이나 조인을 의심할 것"); + } } } } diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index 11f9c88..f89fe5a 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -109,6 +109,15 @@ public static class PatientIdentity : new DiagnosisStore(connection).Diagnoses(context.ComNum, scope); } + /// 수술 행 공급자 — 상병과 같은 정책(필요할 때 1회, 실패는 던져 사유로) + public static Func>> SurgerySourceFor(PatientContext context) + { + var connection = ConfigService.Current.ConnectionString; + return () => connection.Length == 0 + ? new List>() + : new SurgeryStore(connection).Surgeries(context.ComNum); + } + /// /// 이 내원의 진료과 마스터 행 — 못 읽으면 빈 사전. /// 진료과 코드는 문맥의 CodInf(적용일시로 고른 행)에서, 유효기간 일자는 diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index 0291ae7..1de2040 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -68,7 +68,8 @@ public static class PatientSession PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked), PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked), PatientIdentity.HospitalNameOf(picked), - PatientIdentity.DiagnosisSourceFor(picked)) + PatientIdentity.DiagnosisSourceFor(picked), + PatientIdentity.SurgerySourceFor(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 df91122..8638c8a 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -174,11 +174,17 @@ public sealed class PatientTagResolver : ITagValueResolver private readonly Dictionary>> diagnosisCache = new(); + /// 수술 행 공급자 — 상병과 같은 이유로 물을 때 한 번만 읽는다 + private readonly Func>>? surgerySource; + + private IReadOnlyList>? surgeryCache; + public PatientTagResolver(PatientContext context, ITagValueResolver next, string residentNumber = "", IReadOnlyDictionary? address = null, IReadOnlyDictionary? doctor = null, IReadOnlyDictionary? department = null, string hospitalName = "", - Func>>? diagnosisSource = null) + Func>>? diagnosisSource = null, + Func>>? surgerySource = null) { this.context = context; this.next = next; @@ -188,6 +194,7 @@ public sealed class PatientTagResolver : ITagValueResolver this.department = department ?? new Dictionary(StringComparer.OrdinalIgnoreCase); this.hospitalName = hospitalName; this.diagnosisSource = diagnosisSource; + this.surgerySource = surgerySource; } #endregion @@ -215,6 +222,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return diagnosisValue; } + if (FromSurgery(tag) is { } surgeryValue) + { + return surgeryValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -503,6 +514,54 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "상병 행은 있는데 그 값이 비어 있습니다"); } + /// + /// 수술 8종 — 해당 태그가 아니면 null. 전부 첫 행에서 컬럼 하나다 + /// (bzDataInterface.vb:13099-13202 및 :3109 — 각 함수가 Rows(0).Item(...) 을 집는다). + /// GetOprInfDT 는 병원 분기가 없어 미이식 병원 안내가 필요 없다. + /// + private TagValue? FromSurgery(string tag) + { + var column = tag switch + { + "OCM_OPNAME" => "OPNAME", + "OCM_OPRCODNAM" => "OPRCODNAM", + "OCM_DX" => "DX", + "OCM_수술상병" => "OkdNam", + "OCM_수술부위" => "OprRegionName", + "OCM_수술_OprPatETC" => "OprPatEtc", + "OCM_수술집도의" => "OprDtrNam", + "OCM_수술마취의사" => "AneUidCod", + _ => null, + }; + if (column is null) + { + return null; + } + if (surgerySource is null) + { + return new TagValue(false, "수술 조회가 연결되지 않았습니다"); + } + if (surgeryCache is null) + { + try + { + surgeryCache = surgerySource(); + } + catch (Exception ex) + { + return new TagValue(false, $"수술 조회에 실패했습니다: {ex.GetType().Name}"); + } + } + if (surgeryCache.Count == 0) + { + return new TagValue(false, "이 내원에 등록된 수술이 없습니다"); + } + var value = surgeryCache[0].TryGetValue(column, out var v) ? v.Trim() : string.Empty; + return value.Length > 0 + ? new TagValue(true, value) + : new TagValue(false, $"수술 행은 있는데 {column} 값이 비어 있습니다"); + } + /// 주민번호에서 나온 값 — 비면 비었는지 말한다 private TagValue Wrap(string value) => value.Length > 0 ? new TagValue(true, value) @@ -523,6 +582,8 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_상병명_가로", "OCM_상병명_세로", "OCM_상병명_영어_가로", "OCM_상병명_영어_세로", "OCM_상병코드_가로", "OCM_상병코드_세로", "OCM_부상병코드", "OCM_부상병POA", "OCM_주상병명", "OCM_주상병코드", "OCM_주상병POA", + "OCM_OPNAME", "OCM_OPRCODNAM", "OCM_DX", "OCM_수술상병", "OCM_수술부위", + "OCM_수술_OprPatETC", "OCM_수술집도의", "OCM_수술마취의사", }; /// diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index 6f28570..663bd1f 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -225,7 +225,8 @@ public partial class PreviewWindow : Window PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked), PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked), PatientIdentity.HospitalNameOf(picked), - PatientIdentity.DiagnosisSourceFor(picked)); + PatientIdentity.DiagnosisSourceFor(picked), + PatientIdentity.SurgerySourceFor(picked)); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document,