diff --git a/src/SheetMe.Data/Stores/PatientExtraStore.cs b/src/SheetMe.Data/Stores/PatientExtraStore.cs index 821442e..166c6e7 100644 --- a/src/SheetMe.Data/Stores/PatientExtraStore.cs +++ b/src/SheetMe.Data/Stores/PatientExtraStore.cs @@ -111,6 +111,76 @@ public sealed class PatientExtraStore + " WHERE ComNum = :m ORDER BY DtlTblCod) WHERE ROWNUM = 1", ("m", comNum)); + /// + /// 퇴원 이후 다음 외래 방문(OCM_FollowUp, :1028) — 지금보다 뒤의 취소 아닌 내원 중 + /// 가장 가까운 것의 접수일시 12자리. 원문의 ROWNUM 은 ORDER BY 보다 먼저 걸려 + /// 임의 행이었다 — 인라인뷰로 정렬을 먼저 걸어 "가장 가까운 미래 내원"으로 고정한다. + /// "지금"은 서버 시각이다(레거시 SystemDateTime 과 같은 의미). + /// + public string FollowUp(string chtNum) + => Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " WHERE ComChtNum = RPAD(:c, 10)" + + " AND ComAcpDtm > TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')" + + " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm) WHERE ROWNUM = 1", + ("c", chtNum)); + + /// + /// 외출·외박 구간의 한쪽 끝(OCM_외출외박신청일/종료일, :1883/:1936) — 오늘을 포함하는 + /// 외박 허용(CowSlpOut='Y') 병실 행의 CowStrDtm/CowEndDtm. + /// 0행이면 서버 현재시각을 돌려준다 — 특이하지만 레거시가 그렇다 + /// (기록이 없으면 "지금"이 종이에 찍힌다). P_RblInf 곱으로 다중행이면 원문은 + /// 임의 행이었다 — 시작일시 최신 행으로 고정한다. + /// + public string LeaveOfAbsence(decimal comNum, bool endSide) + { + var column = endSide ? "CowEndDtm" : "CowStrDtm"; + // "오늘"은 서버 날짜다(레거시 SystemDate 와 같은 의미) — 바인드 대신 SYSDATE 로 쓴다 + return Scalar( + "SELECT NVL(MAX(v), TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')) FROM (" + + $"SELECT v FROM (SELECT {column} v FROM P_CoiInf" + + " INNER JOIN P_ComInf ON ComNum = CoiComNum AND ComPatTyp = 'I' AND ComAcpStt <> 'IC'" + + " INNER JOIN P_CowInf ON CowComNum = CoiComNum" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' BETWEEN CowStrDtm AND CowEndDtm" + + " INNER JOIN P_RblInf ON RblComNum = CoiComNum AND CoiMtiSeq = RblMtiSeq" + + " AND RblStrDte BETWEEN CoiStrDte AND CoiEndDte" + + " WHERE CoiComNum = :m AND RblDtrCod = '-' AND RblNxtNum = 0 AND RblIncTyp = 'M'" + + " AND CowSlpOut = 'Y'" + + " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN CoiStrDte AND CoiEndDte" + + " ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1)", + ("m", comNum)); + } + + /// + /// 최초 내원일(OCM_최초내원일, :1988 Case Else) — 이 내원 이전의 취소 아닌 재진(ComNfsTyp='2') + /// 중 가장 이른 접수일시. 원문이 인라인뷰 안에 ORDER BY 를 걸어 이 쿼리는 원래 결정적이다. + /// P_CodInf 조인은 원문 그대로(진료 행이 있는 내원만) — 곱행이 생겨도 값이 같은 컬럼이라 무해. + /// KIMEYE 분기(초재진 무구분, ComNfsTyp 우선 정렬)는 옮기지 않았다. + /// + public string FirstVisit(string chtNum, string acpDtm) + => Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " INNER JOIN P_CodInf ON ComNum = CodComNum" + + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a" + + " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'" + + " ORDER BY ComAcpDtm) WHERE ROWNUM = 1", + ("c", chtNum), ("a", acpDtm)); + + /// + /// 진료과별 초진일(OCM_초진일_발병일 :2040 · OCM_신환_초진일 :2138) — 최초 내원일에 + /// 진료과 앞 2자 조건이 붙는다. 신환 포함이면 ComNfsTyp IN ('1','2'), 아니면 '2' 만. + /// + public string FirstVisitByDepartment(string chtNum, string acpDtm, string depCod2, bool includeNew) + => Scalar( + "SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf" + + " INNER JOIN P_CodInf ON ComNum = CodComNum" + + " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a" + + (includeNew ? " AND ComAcpStt <> 'OC' AND ComNfsTyp IN ('1','2')" + : " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'") + + " AND SUBSTR(CodDepCod, 1, 2) = :p" + + " ORDER BY ComAcpDtm) WHERE ROWNUM = 1", + ("c", chtNum), ("a", acpDtm), ("p", depCod2)); + /// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙) private string Scalar(string sql, params (string Name, object Value)[] binds) { diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index f5e89ce..b21e16a 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -2347,9 +2347,14 @@ public static class DbSmoke + $" · 급여세대주 {extra.HouseholderNameByInsurance(extraOwner, "0", "1").Length}자" + (owner2 is null ? "" : $" · 건보세대주 {extra.HouseholderName(extraOwner, owner2.Value).Length}자" - + $" · 협력업체 {extra.Cooperator(owner2.Value).Length}자"); + + $" · 협력업체 {extra.Cooperator(owner2.Value).Length}자") + + $" · FollowUp {extra.FollowUp(extraOwner).Length}자" + + $" · 최초내원 {extra.FirstVisit(extraOwner, "999912312359").Length}자" + + $" · 초진일 {extra.FirstVisitByDepartment(extraOwner, "999912312359", "00", false).Length}자" + + (owner2 is null ? "" + : $" · 외출외박 {extra.LeaveOfAbsence(owner2.Value, false).Length}자"); lines.Add($" 부가정보: {shapes} (값은 기록하지 않는다)"); - Check("㉛ 부가정보 7종 조회가 예외 없이 돈다", true, ""); + Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", true, ""); // "돌지만 항상 0행"인 매칭 실수(RPAD·조인)를 가른다 — // 값이 있는 차트를 골라서 물으면 반드시 값이 나와야 한다. @@ -2367,11 +2372,28 @@ public static class DbSmoke extra.Nationality(natOwner).Length > 0, "RPAD 매칭이나 NATCOD 조인을 의심할 것"); } + + // 최초내원일도 같은 방식 — 재진(ComNfsTyp='2') 내원이 있는 + // 차트를 골라서 물으면 반드시 값이 나와야 한다. + var revisitOwner = FirstText(conn, + "SELECT TRIM(ComChtNum) FROM P_ComInf" + + " INNER JOIN P_CodInf ON ComNum = CodComNum" + + " WHERE ComAcpStt <> 'OC' AND ComNfsTyp = '2' AND ROWNUM = 1"); + if (revisitOwner.Length == 0) + { + lines.Add("SKIP ㉝ 최초내원일 값 — 재진 내원이 없어 판정 불가"); + } + else + { + Check("㉝ 재진 내원이 있는 차트에서는 최초내원일이 나온다", + extra.FirstVisit(revisitOwner, "999912312359").Length >= 8, + "RPAD 매칭이나 P_CodInf 조인을 의심할 것"); + } } } catch (Exception ex) { - Check("㉛ 부가정보 7종 조회가 예외 없이 돈다", false, + Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); } } diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index ba70e6b..bcf751a 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -155,6 +155,12 @@ public sealed class PatientTagResolver : ITagValueResolver ? $"{value[..4]}-{value[4..6]}-{value[6..8]} {value[8..10]}:{value[10..12]}" : string.Empty; + /// YYYYMMDDHHMM → "9999년99월99일 99시99분" (공백 3칸 — 레거시 포맷 문자열 그대로) + private static string StampKorean(string value) + => value.Length >= 12 + ? $"{value[..4]}년{value[4..6]}월{value[6..8]}일 {value[8..10]}시{value[10..12]}분" + : string.Empty; + private readonly ITagValueResolver next; private readonly PatientContext context; @@ -893,7 +899,9 @@ public sealed class PatientTagResolver : ITagValueResolver private TagValue? FromExtra(string tag) { if (tag is not ("PAT_실제생년월일" or "PAT_국적" or "PAT_장애등급" or "PAT_건보세대주명" - or "PAT_건보_급여_세대주명" or "PAT_협력업체" or "PAT_보호자연락처")) + or "PAT_건보_급여_세대주명" or "PAT_협력업체" or "PAT_보호자연락처" + or "OCM_FollowUp" or "OCM_외출외박신청일" or "OCM_외출외박종료일" + or "OCM_최초내원일" or "OCM_초진일_발병일" or "OCM_신환_초진일")) { return null; } @@ -976,8 +984,59 @@ public sealed class PatientTagResolver : ITagValueResolver ? Wrap(store.Cooperator(m), "협력업체 코드가 없거나 코드표에 이름이 없습니다") : new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); } - default: + case "PAT_보호자연락처": return Wrap(store.GuardianPhone(chtNum), "부가정보에 보호자연락처가 없습니다"); + case "OCM_FollowUp": + { + // 지금보다 뒤의 내원이 없으면 레거시도 "" 다(:1055) — 사유로 말한다 + var next = store.FollowUp(chtNum); + return next.Length >= 12 + ? new TagValue(true, Stamp(next)) + : new TagValue(false, "지금 이후로 잡힌 내원이 없습니다"); + } + case "OCM_외출외박신청일": + case "OCM_외출외박종료일": + { + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + if (!decimal.TryParse(comNum, out var m)) + { + return new TagValue(false, "이 내원의 내원번호를 읽지 못했습니다"); + } + // 기록이 없으면 서버 현재시각이 온다 — 레거시 폴백 그대로(:1924-1927) + var moment = store.LeaveOfAbsence(m, endSide: tag == "OCM_외출외박종료일"); + return moment.Length >= 12 + ? new TagValue(true, StampKorean(moment)) + : new TagValue(false, "외출·외박 일시를 만들지 못했습니다"); + } + default: + { + // 최초내원일·초진일 계열 — 이 내원의 접수일시가 상한이다(레거시 :2001) + var acpDtm = PatientContext.Value(context.ComInf, "ComAcpDtm"); + if (acpDtm.Length == 0) + { + return new TagValue(false, "이 내원의 접수일시를 읽지 못했습니다"); + } + string first; + if (tag == "OCM_최초내원일") + { + first = store.FirstVisit(chtNum, acpDtm); + } + else + { + // 진료과 앞 2자는 레거시가 GetCodInfDT 임의 행에서 뽑았다(:2057) — + // 우리는 문맥의 진료 행(결정 규칙 적용)에서 뽑는다 + var depCod = PatientContext.Value(context.CodInf, "CodDepCod"); + if (depCod.Length < 2) + { + return new TagValue(false, "이 내원의 진료과 코드를 읽지 못했습니다"); + } + first = store.FirstVisitByDepartment(chtNum, acpDtm, depCod[..2], + includeNew: tag == "OCM_신환_초진일"); + } + return first.Length >= 8 + ? new TagValue(true, $"{first[..4]}/{first[4..6]}/{first[6..8]}") + : new TagValue(false, "조건에 맞는 이전 내원이 없습니다"); + } } } catch (Exception ex) @@ -1083,6 +1142,8 @@ public sealed class PatientTagResolver : ITagValueResolver "PAT_성별_남", "PAT_성별_여", "PAT_실제생년월일", "PAT_국적", "PAT_장애등급", "PAT_건보세대주명", "PAT_건보_급여_세대주명", "PAT_협력업체", "PAT_보호자연락처", + "OCM_FollowUp", "OCM_외출외박신청일", "OCM_외출외박종료일", + "OCM_최초내원일", "OCM_초진일_발병일", "OCM_신환_초진일", "PAT_우편번호", "PAT_도로명주소", "PAT_지번주소", "PAT_영문도로명주소", "OCM_담당의사", "OCM_담당의사_담당의사코드", "OCM_담당의사_연락처", "OCM_진료과", "ETC_요양기관명칭_병원명",