diff --git a/src/SheetMe.Data/Stores/PatientVisitStore.cs b/src/SheetMe.Data/Stores/PatientVisitStore.cs new file mode 100644 index 0000000..f69da8f --- /dev/null +++ b/src/SheetMe.Data/Stores/PatientVisitStore.cs @@ -0,0 +1,215 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// 환자 검색 방식 — 레거시 fmPatVisitList 의 검색 유형 네 가지 +public enum PatientSearchKind +{ + /// 차트번호 — 레거시는 등호 완전일치다(병원별 zero-pad 후 전달) + ChartNumber, + + /// 성명 — 접두 일치 + Name, + + /// 주민번호 — 하이픈을 떼고 접두 일치 + ResidentNumber, + + /// 휴대전화 — 부분 일치 + MobilePhone, +} + +/// 환자 검색 결과 1건 +public sealed record PatientSummary( + string ChtNum, string Name, string ResNumMasked, string MobilePhone, + string LastVisit, bool Inpatient, bool Shielded, bool Active); + +/// 내원 1건 +public sealed record VisitSummary( + decimal ComNum, string Kind, string AcceptedAt, string LeftAt, + string Department, string Doctor, string Insurance, string State); + +/// +/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회. +/// +/// 레거시에서 옮기지 않는 것 셋. 원본(dtPatVisitList.vb:42, 133)은 +/// ① 바인드 변수를 하나도 쓰지 않는다 — 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다. +/// 성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다. +/// ② 행 제한이 없다(ROWNUM/FETCH 어디에도 없음) — 성명 한 글자로 전 환자가 클라이언트로 온다. +/// ③ TRIM·UPPER 정규화가 없다 — CHAR 고정폭 잔여 공백에 걸린다. +/// 셋 다 이 저장소에서는 규범대로 고친다. +/// 와일드카드는 SQL 리터럴이 아니라 파라미터 값에 붙인다. +/// +/// 컬럼을 다 가져오지 않는다. 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고 +/// M_ZipMst 를 조인하는 등 화면 12열을 채우려 크다. 미리보기 환자 선택에 필요한 것은 +/// 사람을 특정할 수 있는 최소한이므로 그만 읽는다 — 개인정보는 덜 읽는 쪽이 낫다. +/// +/// 주민번호는 마스킹된 것만 읽는다. 레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄우고 +/// 마스킹을 실제로 쓰는 병원은 셋뿐이다(fmPatVisitList.vb:864-869). +/// 여기서는 사람을 가려낼 수 있으면 충분하므로 UDF_GetMaskedResNum 결과만 가져온다. +/// +public sealed class PatientVisitStore +{ + #region Member Fields + /// 기본 행 상한 — 레거시에는 없다. 사람이 눈으로 고르는 목록이라 이보다 많으면 검색어를 좁혀야 한다 + public const int DefaultLimit = 200; + + private readonly string connectionString; + #endregion + + #region Constructors + public PatientVisitStore(string connectionString) => this.connectionString = connectionString; + #endregion + + #region Methods + /// + /// 환자 검색. 검색어가 비면 아무것도 돌려주지 않는다 — + /// 조건 없는 전 환자 조회는 레거시에서도 사고였고 여기서 되살릴 이유가 없다. + /// + public List Search(PatientSearchKind kind, string term, int max = DefaultLimit) + { + var value = (term ?? string.Empty).Trim(); + if (value.Length == 0) + { + return new List(); + } + // 주민번호는 하이픈을 떼고 본다(레거시 :96-100 과 같다). + // 최소 2글자 — 레거시도 이것만은 제한한다(fmPatVisitList.vb:761-763). + if (kind == PatientSearchKind.ResidentNumber) + { + value = value.Replace("-", string.Empty); + if (value.Length < 2) + { + return new List(); + } + } + + var (where, bound) = kind switch + { + // 레거시와 같이 완전일치. 호출부가 병원 규약대로 자릿수를 맞춰 넘긴다. + PatientSearchKind.ChartNumber => ("TRIM(PatChtNum) = :v", value), + PatientSearchKind.Name => ("PatNam LIKE :v", value + "%"), + PatientSearchKind.ResidentNumber => ("PatResNum LIKE :v", value + "%"), + // 레거시는 양쪽 와일드카드라 인덱스를 못 탄다. 뒤에서 찾는 것이 실사용이라 그대로 두되 + // 행 상한이 있으므로 전건 스캔이 화면까지 오지는 않는다. + _ => ("PatMblPhn LIKE :v", "%" + value + "%"), + }; + + using var connection = Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = + "SELECT * FROM (" + + " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam," + + " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked," + + " NVL(PatMblPhn,' ') Mbl," + + " NVL(B.LastVisit,' ') LastVisit," + + " NVL(I.ComNum, 0) InP," + + " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon" + + " FROM P_PatInf A" + // 최종 내원일시 — 레거시와 같은 서브쿼리(취소 내원 제외) + + " LEFT JOIN (SELECT ComChtNum, MAX(ComAcpDtm) LastVisit FROM P_ComInf" + + " WHERE ComAcpStt <> 'OC' GROUP BY ComChtNum) B" + + " ON A.PatChtNum = B.ComChtNum" + // 지금 재원 중인가 — 레거시는 오늘 날짜를 클라이언트에서 넣지만 여기서는 DB 시각을 쓴다 + + " LEFT JOIN (SELECT ComChtNum, MIN(ComNum) ComNum FROM P_ComInf" + + " WHERE ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')" + + " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm" + + " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'" + + " GROUP BY ComChtNum) I" + + " ON A.PatChtNum = I.ComChtNum" + + $" WHERE {where}" + + " ORDER BY PatNam, PatChtNum" + + $") WHERE ROWNUM <= {max}"; + command.Parameters.Add(new OracleParameter("v", bound)); + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(new PatientSummary( + reader.GetString(0).Trim(), + reader.GetString(1).Trim(), + reader.GetString(2).Trim(), + reader.GetString(3).Trim(), + reader.GetString(4).Trim(), + reader.GetDecimal(5) > 0, + reader.GetString(6).Trim() == "Y", + reader.GetString(7).Trim() != "N")); + } + return result; + } + + /// + /// 이 환자의 내원 목록 — 최근 것이 위. + /// + /// 레거시 GetComInfo 는 조인 10개로 금액 열까지 채우려 하지만 + /// 그 여섯 열은 값을 받지 못해 화면에서 늘 비어 있다(fmPatVisitList.vb enum 선언에만 존재). + /// 옮기지 않는다. 여기서는 어느 내원인지 사람이 고를 수 있을 만큼만 읽는다. + /// + /// 기간 PK 를 가진 마스터(M_DepMst·M_UidMst·M_InsMst)는 그냥 조인하면 같은 내원이 여러 번 뜬다 — + /// ROW_NUMBER() … Rn = 1 로 접는다(OracleLegacyFormStore.cs:94-100 과 같은 이유). + /// + public List ListVisits(string chtNum, int max = DefaultLimit) + { + var key = (chtNum ?? string.Empty).Trim(); + if (key.Length == 0) + { + return new List(); + } + using var connection = Open(); + using var command = connection.CreateCommand(); + command.BindByName = true; + command.CommandText = + "SELECT * FROM (" + + " SELECT A.ComNum," + + " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind," + + " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt," + + " NVL(Dep.Nam,' ') Dep, NVL(Doc.Nam,' ') Dtr, NVL(Ins.Nam,' ') Ins," + + " NVL(Stt.DtlCodNam,' ') Stt" + + " FROM P_ComInf A" + + " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0" + + " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0" + + " LEFT JOIN (SELECT TRIM(DepCod) Cod, DepKorNam Nam," + + " ROW_NUMBER() OVER (PARTITION BY TRIM(DepCod) ORDER BY DepStrDte DESC) Rn" + + " FROM M_DepMst) Dep ON Dep.Cod = TRIM(B.CodDepCod) AND Dep.Rn = 1" + + " LEFT JOIN (SELECT TRIM(UidCod) Cod, UidNam Nam," + + " ROW_NUMBER() OVER (PARTITION BY TRIM(UidCod) ORDER BY UidStrDte DESC) Rn" + + " FROM M_UidMst) Doc ON Doc.Cod = TRIM(B.CodDtrCod) AND Doc.Rn = 1" + + " LEFT JOIN (SELECT TRIM(InsCod) Cod, InsNam Nam," + + " ROW_NUMBER() OVER (PARTITION BY TRIM(InsCod) ORDER BY InsStrDte DESC) Rn" + + " FROM M_InsMst) Ins ON Ins.Cod = TRIM(C.CoiInsCod) AND Ins.Rn = 1" + + " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt" + + " WHERE TRIM(A.ComChtNum) = :c" + + " ORDER BY A.ComAcpDtm DESC" + + $") WHERE ROWNUM <= {max}"; + command.Parameters.Add(new OracleParameter("c", key)); + LastSql = command.CommandText; + + var result = new List(); + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + result.Add(new VisitSummary( + reader.GetDecimal(0), + reader.GetString(1).Trim(), + reader.GetString(2).Trim(), + reader.GetString(3).Trim(), + reader.GetString(4).Trim(), + reader.GetString(5).Trim(), + reader.GetString(6).Trim(), + reader.GetString(7).Trim())); + } + return result; + } + + /// 마지막으로 실행한 SQL — 진단이 예외를 진단할 때만 쓴다(문법 오류는 문장을 봐야 안다) + public static string LastSql { get; private set; } = string.Empty; + + private OracleConnection Open() + { + var connection = new OracleConnection(connectionString); + connection.Open(); + return connection; + } + #endregion +} diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 3983cba..af44f40 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -158,6 +158,11 @@ public partial class App : Application return Diagnostics.DbSmoke.RunReadSmoke(args[1], max); } + if (args.Length >= 3 && args[0] == "--db-patient") + { + return Diagnostics.DbSmoke.RunPatientProbe(args[1], args[2]); + } + if (args.Length >= 3 && args[0] == "--db-modify-smoke") { return Diagnostics.DbSmoke.RunModifySmoke(args[1], args[2]); diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 489ff5c..a0f39f5 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -1825,6 +1825,84 @@ public static class DbSmoke /// 이 진단이 만든 이력 행을 SdgKey 로 지운다. 지우는 것은 방금 만든 그 행 하나뿐이고 /// SdgDelYon='Y' 조건을 함께 걸어 활성 행은 어떤 경우에도 지워지지 않게 한다. /// + /// + /// 환자·내원 조회가 실제 DB 에서 도는가--db-patient <검색어> <리포트>. + /// + /// 컴파일이 통과해도 컬럼명이 틀리면 화면에서만 터진다. 레거시 SELECT 를 그대로 옮기지 않고 + /// 최소 컬럼으로 다시 썼기 때문에 그 컬럼들이 실재하는지를 여기서 확인한다. + /// + /// 개인정보를 리포트에 남기지 않는다 — 건수와 컬럼 유무만 적고 이름·주민번호는 찍지 않는다. + /// 진단 리포트는 파일로 남고 접근 통제가 DB 보다 느슨하다. + /// + public static int RunPatientProbe(string term, string reportPath) + { + var lines = new List(); + var ok = true; + void Check(string name, bool pass, string? detail = null) + { + ok &= pass; + lines.Add($"{(pass ? "PASS" : "FAIL")} {name}{(pass || detail is null ? string.Empty : " — " + detail)}"); + } + + try + { + var store = new PatientVisitStore(ConfigService.Current.ConnectionString); + + // 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다 + foreach (var kind in new[] + { + PatientSearchKind.ChartNumber, PatientSearchKind.Name, + PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone, + }) + { + var found = store.Search(kind, term); + lines.Add($" {kind,-16} {found.Count}건"); + } + Check("① 검색 네 갈래가 예외 없이 돈다", true); + + // 조건 없는 전 환자 조회를 막는가 — 레거시에는 이 방어가 없다 + Check("② 빈 검색어는 아무것도 돌려주지 않는다", + store.Search(PatientSearchKind.Name, " ").Count == 0); + Check("③ 주민번호 1글자는 막는다", + store.Search(PatientSearchKind.ResidentNumber, "8").Count == 0); + + // 따옴표가 SQL 로 새지 않는가 — 레거시는 이스케이프가 없어 그대로 주입된다 + var quoted = store.Search(PatientSearchKind.Name, "'||'"); + Check("④ 따옴표가 SQL 로 새지 않는다", quoted.Count == 0, $"{quoted.Count}건 (예외 없이 0건이어야 한다)"); + + // 행 상한이 실제로 걸리는가 + var capped = store.Search(PatientSearchKind.Name, term, max: 3); + Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건"); + + // 내원 목록 — 검색으로 찾은 첫 환자로 + var people = store.Search(PatientSearchKind.Name, term, max: 1); + if (people.Count == 0) + { + lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가" + + " (다른 검색어로 다시 돌려야 한다)"); + } + else + { + var visits = store.ListVisits(people[0].ChtNum); + lines.Add($" 내원 {visits.Count}건"); + Check("⑥ 내원 목록이 예외 없이 돈다", true); + // 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다 + Check("⑦ 같은 내원이 중복되지 않는다", + visits.Select(v => v.ComNum).Distinct().Count() == visits.Count, + $"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}"); + } + + lines.Add(string.Empty); + lines.Add($"결과: {(ok ? "전건 통과" : "실패 있음")}"); + return Finish(lines, reportPath, ok ? 0 : 1); + } + catch (Exception ex) + { + lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}"); + return Finish(lines, reportPath, 2); + } + } + public static int RunModifySmoke(string shtCod, string reportPath) { var lines = new List();