From 7dcc4c2e48b6f9edbd0c91093da8dc4cef9f6a73 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 19:23:25 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A7=84=EB=8B=A8=EC=84=9C=20=EC=9D=98?= =?UTF-8?q?=EC=82=AC=C2=B7=EC=88=98=EC=88=A0=EC=9D=BC=EC=9E=90=C2=B7?= =?UTF-8?q?=EC=99=B8=EB=9E=98=EC=8B=A4=EC=A7=84=EB=A3=8C=EC=9D=BC=2016?= =?UTF-8?q?=EC=A2=85=20=E2=80=94=20193=EC=A2=85=20=E2=86=92=20209=EC=A2=85?= =?UTF-8?q?=20(=EC=A1=B0=EC=82=AC=20=EB=9D=BC=EC=9A=B4=EB=93=9C=206)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 진단서 계열 8종(bzDataInterface.vb:4380-4845): 로그인 사용자가 의사(UidDtrYon='Y')면 로그인 사용자의 값, 아니면 담당의의 값 — 시점·기준일(입원 CodEndDtm·외래 접수일)이 담당의 프리페치와 같은 판정이라 그 행을 재사용한다. 의사명(UidNam)·명_외래(입원이면 조회 없이 빈)·영문(UidEngNam)·면허(UidLicNum)·전문의(UidSpcLic)· 세부전문의(UidDtlSpcLic)·전문과목(개원일 2022-07-01 분기, TYRCH 미이식)·청구진료과목_로그인(DepBilCod→BDPTBL, 원문의 같은 쿼리 2회 실행은 1회로). 로그인 행에 영문명·세부전문의번호 컬럼 2개 추가. 값 5종: OCM_당뇨식칼로리_List(:3569 — 원문부터 결정적), OCM_수술일자(:2977 Case Else — 첫 수술 OprKey ASC; PURME·BSGH· HIMCHAN·GJHNSS 분기 미이식), OCM_수술일자_마지막수술(:2999), OCM_외래실진료일/_과목별(:1217/:1503 — 신환 첫 접수(폴백 전체 첫 접수)~마지막 접수 구간의 과 매칭 접수일 전부 "YYYY/MM/DD" ", " 결합. 같은 날 중복 출력도 원문 그대로. 본체 종료일 쿼리의 문자열 연결은 같은 값이라 바인드로 정상화). 사유 완결 3종: OCM_외래실진료일수(:1645 — 차트번호 자리에 시작일 바인드 + 없는 컬럼(OcmAcpDtm) 정렬, 상시 ORA-00904→빈 값인 레거시 결함), ETC_승인의사(:3892 — EmrKey 의존, 미리보기 부재), OCM_수술소독간호사(:3028 — PURME 전용, 그 외 병원은 빈 SQL 실행). - dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 209종) - --db-patient ①~㉟ 전건 통과 (㉟ 4종 추가 — 청구과목 실값 4자) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Fable 5 --- src/SheetMe.Data/Stores/PatientExtraStore.cs | 88 +++++++++ src/SheetMe.Data/Stores/UserContextStore.cs | 10 +- src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 4 + .../Services/PatientTagResolver.cs | 182 ++++++++++++++++++ 4 files changed, 281 insertions(+), 3 deletions(-) diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs index 3bfea53..b9421b1 100644 --- a/src/SheetMe.Data/Stores/PatientExtraStore.cs +++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs @@ -526,6 +526,94 @@ public sealed class PatientExtraStore + " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm", ("m", comNum)); + /// + /// 청구 진료과목 이름(OCM_진단서청구진료과목_로그인, :4790) — 진료과의 청구코드 + /// (DepBilCod)를 코드표(BDPTBL)로 푼다. 기준일은 오늘(원문 SystemDate). + /// 원문 Rows(0) 무가드 — 한 행으로 고정한다. + /// + public string BillingDepartmentName(string depCod) + => depCod.Trim().Length == 0 ? string.Empty + : Scalar( + "SELECT v FROM (SELECT DtlCodNam v FROM M_DepMst" + + " INNER JOIN M_DtlMst ON DepBilCod = DtlCod AND DtlTblCod = 'BDPTBL'" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte" + + " WHERE TRIM(DepCod) = :d" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte)" + + " WHERE ROWNUM = 1", + ("d", depCod.Trim())); + + /// + /// 당뇨식 칼로리(OCM_당뇨식칼로리_List, :3569) — 당뇨식 중 최신 식이일의 칼로리 1건. + /// 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다(식이일 동률만 모호). + /// + public string DiabetesMealCalorie(string chtNum) + => Scalar( + "SELECT ImlCal FROM (SELECT ImlCal FROM O_ImlInf" + + " INNER JOIN P_ComInf ON ComNum = ImlComNum AND ComChtNum = RPAD(:c, 10)" + + " INNER JOIN M_MgdMst ON MgdCod = ImlMgdCod" + + " WHERE MgdKorNam LIKE '%당뇨%' AND TO_NUMBER(ImlCal) > 0" + + " ORDER BY ImlDte DESC) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// + /// 수술 신청의 수술일자(OCM_수술일자 Case Else :2977 · OCM_수술일자_마지막수술 :2999) — + /// 확정(OprStt='E') 수술의 OprDte. 본체는 첫 수술(OprKey ASC), 마지막수술은 최신 + /// (OprKey DESC). PURME(마취 오더)·BSGH/HIMCHAN/GJHNSS(DESC 뒤집기) 분기는 미이식. + /// + public string SurgeryDate(decimal comNum, bool latestFirst) + => Scalar( + "SELECT v FROM (SELECT OprDte v FROM S_OprInf" + + " WHERE OprComNum = :m AND OprStt = 'E'" + + $" ORDER BY OprKey {(latestFirst ? "DESC" : "ASC")}) WHERE ROWNUM = 1", + ("m", comNum)); + + /// + /// 외래 실진료일 목록(OCM_외래실진료일 :1217 · _과목별 :1503) — 신환('1') 첫 접수 + /// (없으면 전체 첫 접수)부터 마지막 접수까지, 지정 진료과의 접수일시 전부(시간순). + /// exactDepartment=false 면 과 앞 2자 매칭(본체), true 면 전체 코드 일치(과목별). + /// 원문 세 선행 쿼리 모두 인라인뷰 정렬 + ROWNUM 이라 결정적이다. + /// 본체 원문의 종료일 쿼리는 차트번호를 문자열 연결로 넣지만 값이 같아 바인드로 정상화했다. + /// + public IReadOnlyList OutpatientVisitDays(string chtNum, string acpDtm, string depCod, bool exactDepartment) + { + var first = Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a" + + " AND ComAcpStt <> 'OC' AND ComNfsTyp = '1'" + + " ORDER BY ComAcpDtm) WHERE ROWNUM = 1", + ("c", chtNum), ("a", acpDtm)); + if (first.Length < 8) + { + first = Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'" + + " ORDER BY ComAcpDtm ASC) WHERE ROWNUM = 1", + ("c", chtNum)); + } + if (first.Length < 8) + { + return Array.Empty(); + } + var last = Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'" + + " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1", + ("c", chtNum)); + var depFilter = exactDepartment + ? " AND CodDepCod = :p" : " AND SUBSTR(CodDepCod, 1, 2) = :p"; + return Rows( + "SELECT ComAcpDtm FROM P_ComInf" + + " INNER JOIN P_CodInf ON ComNum = CodComNum" + + " WHERE ComChtNum = RPAD(:c, 10)" + + " AND ComAcpDtm >= :s || '0000' AND ComAcpDtm <= :e || '9999'" + + depFilter + " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm", + ("c", chtNum), ("s", first[..8]), ("e", last.Length >= 8 ? last[..8] : string.Empty), + ("p", depCod)) + .Select(r => r.TryGetValue("ComAcpDtm", out var v) ? v : string.Empty) + .Where(v => v.Length >= 8) + .ToArray(); + } + /// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙) private string Scalar(string sql, params (string Name, object Value)[] binds) { diff --git a/src/SheetMe.Data/Stores/UserContextStore.cs b/src/SheetMe.Data/Stores/UserContextStore.cs index 43ed2f0..d926bba 100644 --- a/src/SheetMe.Data/Stores/UserContextStore.cs +++ b/src/SheetMe.Data/Stores/UserContextStore.cs @@ -21,7 +21,11 @@ public sealed record HisUser( /// 직종명(M_DtsMst JOBCOD 의 DtsCodNam) — ETC_로그인_직종 string JobName = "", /// 부서 전화번호(M_DepMst.DepTelNum) — ETC_접속유저_부서번호(이름과 달리 전화번호다) - string DepPhone = "") + string DepPhone = "", + /// 영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래 + string EnglishName = "", + /// 세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호 + string DetailSpecialistNumber = "") { /// 미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다 public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); @@ -81,7 +85,7 @@ public sealed class UserContextStore " NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " + " NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " + " NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " + - " NVL(d.DepTelNum,' ') " + + " NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' ') " + "FROM M_UidMst u " + "LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " + @@ -105,7 +109,7 @@ public sealed class UserContextStore reader.GetString(4).Trim(), reader.GetString(5).Trim(), reader.GetString(6).Trim(), reader.GetString(7).Trim(), reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(), - reader.GetString(11).Trim()); + reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim()); } #endregion } diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 8fbe010..288ee9c 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2444,6 +2444,10 @@ public static class DbSmoke ("퇴원약오더", () => owner2 is null ? "-" : extra.DischargeMedicationOrderDate(owner2.Value).Length.ToString()), ("응급증상", () => owner2 is null ? "-" : (extra.EmergencySymptomInsYon(owner2.Value) ?? "[없음]")), ("전과내역", () => owner2 is null ? "-" : extra.TransferHistory(owner2.Value).Count.ToString()), + ("당뇨식이", () => extra.DiabetesMealCalorie(extraOwner).Length.ToString()), + ("수술일자", () => owner2 is null ? "-" : extra.SurgeryDate(owner2.Value, false).Length.ToString()), + ("외래실진료일", () => extra.OutpatientVisitDays(extraOwner, "999912312359", "05", false).Count.ToString()), + ("청구과목", () => extra.BillingDepartmentName("0506").Length.ToString()), }; var shapes2 = new List(); var failed = new List(); diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index 746c7a2..06939e3 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -352,6 +352,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return bodyValue; } + if (FromCertificate(tag) is { } certValue) + { + return certValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -959,6 +963,121 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); } + /// + /// 진단서 의사 계열 8종(bzDataInterface.vb:4380-4845). + /// + /// 로그인 사용자가 의사면 로그인 사용자의 값이다(UidDtrYon='Y' — 진단서는 + /// 보는 의사 이름으로 나간다). 아니면 담당의 — 시점(퇴원/현재/접수)과 기준일 + /// (입원 CodEndDtm·외래 접수일)이 담당의 프리페치와 같은 판정이라 그 행을 재사용한다. + /// + private TagValue? FromCertificate(string tag) + { + if (tag is not ("OCM_진단서의사명" or "OCM_진단서의사명_외래" or "OCM_진단서의사_영문" + or "OCM_진단서의사면허번호" or "OCM_진단서의사전문의번호" or "OCM_진단서의사세부전문의번호" + or "OCM_진단서전문과목" or "OCM_진단서청구진료과목_로그인")) + { + return null; + } + var loginDoctor = UserSession.Current.DoctorYn == "Y"; + string Doc(string column) => doctor.TryGetValue(column, out var v) ? v.Trim() : string.Empty; + TagValue FromColumn(string column, string loginValue) + { + if (loginDoctor) + { + return loginValue.Length > 0 + ? new TagValue(true, loginValue) + : new TagValue(false, "로그인 사용자 행에 이 값이 비어 있습니다"); + } + if (tag == "OCM_진단서의사명_외래" && Com(context, "ComPatTyp") == "I") + { + return new TagValue(true, string.Empty); // 입원은 레거시가 조회 없이 "" 다(:4418) + } + if (doctor.Count == 0) + { + return new TagValue(false, "이 내원의 담당의를 찾지 못했습니다"); + } + var value = Doc(column); + return value.Length > 0 + ? new TagValue(true, value) + : new TagValue(false, $"담당의 행에 {column} 값이 비어 있습니다"); + } + switch (tag) + { + case "OCM_진단서의사명": + case "OCM_진단서의사명_외래": + return FromColumn("UidNam", UserSession.Current.UidNam); + case "OCM_진단서의사_영문": + return FromColumn("UidEngNam", UserSession.Current.EnglishName); + case "OCM_진단서의사면허번호": + return FromColumn("UidLicNum", UserSession.Current.LicenseNumber); + case "OCM_진단서의사전문의번호": + return FromColumn("UidSpcLic", UserSession.Current.SpecialistNumber); + case "OCM_진단서의사세부전문의번호": + return FromColumn("UidDtlSpcLic", UserSession.Current.DetailSpecialistNumber); + case "OCM_진단서전문과목": + { + // 로그인 의사면 로그인 부서, 아니면 담당의 진료과(:4663-4725). + // TYRCH(대외명칭만) 분기는 미이식 — Else 갈래: 개원일 2022-07-01 이후면 + // 대외명칭 우선(비면 한글명), 이전이면 한글명. + string office, korean; + if (loginDoctor) + { + office = UserSession.Current.DepOfficialName; + korean = UserSession.Current.DepNam; + } + else + { + if (doctor.Count == 0) + { + return new TagValue(false, "이 내원의 담당의를 찾지 못했습니다"); + } + if (department.Count == 0) + { + return new TagValue(false, "이 내원의 진료과를 찾지 못했습니다"); + } + office = PatientContext.Value(department, "DepOfcNam").Trim(); + korean = PatientContext.Value(department, "DepKorNam").Trim(); + } + var open = PatientContext.Value(hospital, "HspStrDte"); + var name = string.CompareOrdinal(open, "20220701") >= 0 && office.Length > 0 + ? office : korean; + return name.Length > 0 + ? new TagValue(true, name) + : new TagValue(false, "진료과 이름이 비어 있습니다"); + } + default: // OCM_진단서청구진료과목_로그인 + { + var depCod = loginDoctor + ? UserSession.Current.DepCod + : PatientContext.Value(context.CodInf, "CodDepCod"); + if (depCod.Length == 0) + { + return new TagValue(false, loginDoctor + ? "로그인 사용자에 부서 코드가 없습니다" + : "이 내원의 진료과 코드를 읽지 못했습니다"); + } + if (!loginDoctor && doctor.Count == 0) + { + return new TagValue(false, "이 내원의 담당의를 찾지 못했습니다"); // 레거시도 즉시 ""(:4820) + } + if (extraStoreSource?.Invoke() is not { } billingStore) + { + return new TagValue(false, "부가정보 조회가 연결되지 않았습니다"); + } + try + { + return billingStore.BillingDepartmentName(depCod) is { Length: > 0 } billing + ? new TagValue(true, billing) + : new TagValue(false, "청구 코드표(BDPTBL)에 짝이 되는 행이 없습니다"); + } + catch (Exception ex) + { + return new TagValue(false, $"청구과목 조회에 실패했습니다: {ex.GetType().Name}"); + } + } + } + } + /// /// BST·BMI·체중 파생 6종(bzDataInterface.vb:12042-12420). /// @@ -1383,6 +1502,9 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_계산유형", "OCM_보험유형", "OCM_수납일시", "OCM_진료기간", "OCM_진료기간_진료과", "OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역", + "OCM_당뇨식칼로리_List", "OCM_수술일자", "OCM_수술일자_마지막수술", + "OCM_외래실진료일", "OCM_외래실진료일_과목별", "OCM_외래실진료일수", + "ETC_승인의사", "OCM_수술소독간호사", }; /// @@ -1767,6 +1889,60 @@ public sealed class PatientTagResolver : ITagValueResolver return new TagValue(true, (tag == "OCM_응급증상여부_YES") == yes ? "Y" : string.Empty); } + case "OCM_당뇨식칼로리_List": + return Wrap(store.DiabetesMealCalorie(chtNum), "당뇨식 식이 기록(O_ImlInf)이 없습니다"); + case "OCM_수술일자": + case "OCM_수술일자_마지막수술": + { + // 본체는 첫 수술(OprKey ASC), 마지막수술은 최신(:2977/:2999). + // PURME(마취 오더)·BSGH/HIMCHAN/GJHNSS(내림차순) 분기는 미이식. + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + if (!decimal.TryParse(comNum, out var m)) + { + return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + var opDate = store.SurgeryDate(m, latestFirst: tag == "OCM_수술일자_마지막수술"); + return opDate.Length >= 8 + ? new TagValue(true, Hyphenate(opDate[..8])) + : new TagValue(false, "확정(OprStt='E')된 수술 신청(S_OprInf)이 없습니다"); + } + case "OCM_외래실진료일": + case "OCM_외래실진료일_과목별": + { + var acp = PatientContext.Value(context.ComInf, "ComAcpDtm"); + var depCod = PatientContext.Value(context.CodInf, "CodDepCod"); + if (acp.Length < 8 || depCod.Length == 0) + { + return new TagValue(false, "이 내원의 접수일시·진료과를 읽지 못했습니다"); + } + // 본체는 과 앞 2자, 과목별은 전체 코드 일치(:1331/:1617) + var exact = tag == "OCM_외래실진료일_과목별"; + var days = store.OutpatientVisitDays(chtNum, acp, + exact ? depCod : depCod[..Math.Min(2, depCod.Length)], exact); + if (days.Count == 0) + { + return new TagValue(false, "구간 안에 이 진료과의 접수가 없습니다"); + } + // "YYYY/MM/DD" 를 ", " 로 — 마지막 뒤에는 없음(:1341-1350). DISTINCT 가 + // 없어 같은 날 복수 접수는 중복 출력된다(레거시 그대로). + return new TagValue(true, string.Join(", ", + days.Select(d => $"{d[..4]}/{d[4..6]}/{d[6..8]}"))); + } + case "OCM_외래실진료일수": + // 원문(:1645-1762)의 건수 쿼리가 차트번호 자리에 시작일을 바인드하고 + // (ComChtNum = :sStrDte) 존재하지 않는 컬럼(OcmAcpDtm)으로 정렬한다 — + // 실행마다 ORA-00904 → 오류창 → 빈 값이다. 고치면 없던 값이 생긴다. + return new TagValue(false, + "레거시 결함(잘못된 바인드·없는 컬럼 정렬)으로 항상 빈 값인 태그입니다"); + case "ETC_승인의사": + // 저장된 기록(EmrKey+ShtCod)의 승인자와 현재 작업자를 비교한다(:3892) — + // 미리보기에는 저장된 기록 키가 없다. Cosign 과 같은 이유로 완결. + return new TagValue(false, + "저장된 기록에서만 값이 됩니다 — 미리보기에는 기록 키(EmrKey)가 없습니다"); + case "OCM_수술소독간호사": + // PURME 전용(:3028) — 그 밖의 병원은 빈 SQL 을 실행해 항상 빈 값이다 + return new TagValue(false, + "PURME 전용 태그입니다 — 그 밖의 병원에서는 레거시도 항상 빈 값입니다"); case "OCM_전과내역": { var comNum = PatientContext.Value(context.ComInf, "ComNum"); @@ -1973,6 +2149,12 @@ public sealed class PatientTagResolver : ITagValueResolver "OCM_병실인실", "OCM_병실차액", "OCM_진료과_전화번호", "OCM_전과일시", "OCM_전과일자", "OCM_계산유형", "OCM_보험유형", "OCM_수납일시", "OCM_진료기간", "OCM_진료기간_진료과", "OCM_응급증상여부_YES", "OCM_응급증상여부_NO", "OCM_전과내역", + "OCM_진단서의사명", "OCM_진단서의사명_외래", "OCM_진단서의사_영문", + "OCM_진단서의사면허번호", "OCM_진단서의사전문의번호", "OCM_진단서의사세부전문의번호", + "OCM_진단서전문과목", "OCM_진단서청구진료과목_로그인", + "OCM_당뇨식칼로리_List", "OCM_수술일자", "OCM_수술일자_마지막수술", + "OCM_외래실진료일", "OCM_외래실진료일_과목별", "OCM_외래실진료일수", + "ETC_승인의사", "OCM_수술소독간호사", "OCM_입원시간", "OCM_입원일시", "OCM_입원일시_영문", "OCM_입실시간", "OCM_의사퇴원예고일시", "OCM_퇴원예정일시", "OCM_퇴원시간", "OCM_퇴원일시", "OCM_퇴원예고일시", "OCM_재원일수", "OCM_퇴원예고재원일수", "OCM_입원일자_낮병동",