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_요양기관명칭_병원명",