From be9889ed4e25b57a2b9ef609238da58609f83c0e Mon Sep 17 00:00:00 2001 From: Msystech Date: Tue, 18 Aug 2026 11:43:34 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=81=EC=9A=A9=EC=9D=BC=EC=8B=9C=20?= =?UTF-8?q?=ED=8C=90=EC=A0=95=EC=9D=84=20=ED=91=9C=EB=A1=9C=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4=20=E2=80=94=20=ED=95=9C=20=EC=B9=B8?= =?UTF-8?q?=20=EC=96=B4=EA=B8=8B=EB=82=98=EB=A9=B4=20=ED=83=9C=EA=B7=B8?= =?UTF-8?q?=EA=B0=80=20=ED=86=B5=EC=A7=B8=EB=A1=9C=20=EB=B9=88=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 미리보기 레거시 호환 계획 A3 의 세 번째 조각. 순수 함수 + 테스트 9건. 왜 이것부터인가. 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 — 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf), 병동·병실(P_CowInf) 모두 :AdpDtm BETWEEN StrDtm AND EndDtm 이다. 시각이 한 칸 어긋나면 조인이 0행이 되고 태그가 통째로 빈 값이 된다 — 값이 틀리는 것이 아니라 아무것도 안 나오고, 사용자는 "미리보기가 고장났다"고 읽는다. 갈래가 여섯이고 각각 경계 조건이 다르다: 외래 → 접수일시 그대로(자격 구간을 보지 않는다) 입원 First → 자격 시작과 접수 중 늦은 쪽 입원 Last, 재원 중(CoiEndDte=29991231) → 지금. 단 입원 전환이 미래면 접수 시각 입원 Last, 퇴원 → 자격 종료와 퇴원 중 이른 쪽 호출자 지정(8자리/12자리) → 내원 구간 안으로 가둔다 해석 불가 → 자동 판정으로 떨어진다(빈 값을 내보내면 조회가 전부 0행이다) 저장소에 묻지 않고 순수 함수로 뺐다. 안에 넣으면 실DB 없이는 한 갈래도 확인할 수 없다. 지금은 여섯 갈래를 전부 표로 고정했다. 시각 비교는 문자열 사전순이다 — 레거시가 그렇게 하고, 고정폭 YYYYMMDDHHMM 은 사전순이 곧 시간순이다. DateTime 으로 바꾸면 잘못된 값(빈 문자열·공백)에서 예외가 난다. CHAR 잔여 공백을 흡수한다 — 안 하면 사전순 비교가 어긋난다(검사로 못 박음). 게이트: 테스트 312/312(신규 9), --edit-smoke 0실패, --dialog-shots FAIL 0, 빌드 경고 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일. 남은 것: 이 시각으로 5행을 읽는 PatientContextStore, 환자 선택 UI, 그리고 그 문맥을 쓰는 태그 해석기. Co-Authored-By: Claude Opus 5 --- src/SheetMe.Core/Catalog/VisitMoment.cs | 146 +++++++++++++++++++ tests/SheetMe.Core.Tests/VisitMomentTests.cs | 120 +++++++++++++++ 2 files changed, 266 insertions(+) create mode 100644 src/SheetMe.Core/Catalog/VisitMoment.cs create mode 100644 tests/SheetMe.Core.Tests/VisitMomentTests.cs diff --git a/src/SheetMe.Core/Catalog/VisitMoment.cs b/src/SheetMe.Core/Catalog/VisitMoment.cs new file mode 100644 index 0000000..9ea0ac3 --- /dev/null +++ b/src/SheetMe.Core/Catalog/VisitMoment.cs @@ -0,0 +1,146 @@ +namespace SheetMe.Core.Catalog; + +/// 내원 구간에서 어느 끝을 기준으로 삼을지 — 레거시 EN_FirstorLast +public enum VisitEdge +{ + /// 마지막 시점(기본) — 퇴원·자격 종료 쪽 + Last, + + /// 첫 시점 — 접수·자격 시작 쪽 + First, +} + +/// 적용일시 판정에 필요한 내원 정보 — 전부 YYYYMMDDHHMM / YYYYMMDD 문자열이다 +/// 입원인가(ComPatTyp = 'I') +/// ComAcpDtm — 접수일시(12자리) +/// ComLevDtm — 퇴원일시(12자리). 재원 중이면 비거나 미래값 +/// CoiStrDte — 자격 시작일(8자리) +/// CoiEndDte — 자격 종료일(8자리). 재원 중이면 29991231 +public readonly record struct VisitWindow( + bool Inpatient, string AcceptedAt, string LeftAt, string CoverageFrom, string CoverageTo); + +/// +/// 내원의 적용일시(AdpDtm)를 정한다 — 레거시 bzPatientInfo.SetPatientInfo + +/// dtCommonLib.GetComAdpDtm 의 판정을 옮긴 것. +/// +/// 왜 이것이 중요한가. 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 — +/// 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf), 병동·병실(P_CowInf) 모두 +/// :AdpDtm BETWEEN StrDtm AND EndDtm 이다. 시각이 한 칸 어긋나면 조인이 0행이 되고 +/// 태그가 전부 빈 값이 된다. 값이 틀리는 것이 아니라 통째로 안 나온다. +/// +/// 순수 함수로 두는 이유. 갈래가 여섯이고(외래/입원×First/Last, 재원 중, 호출자 지정) +/// 각각 경계 조건이 다르다. 저장소 안에 묻어 두면 실DB 없이는 한 갈래도 확인할 수 없다. +/// +/// 시각 비교는 문자열 사전순으로 한다 — 레거시가 그렇게 하고, 고정폭 YYYYMMDDHHMM 은 +/// 사전순이 곧 시간순이다. DateTime 으로 바꾸면 잘못된 값(빈 문자열·공백)에서 예외가 난다. +/// +public static class VisitMoment +{ + #region Member Fields + /// 자격 종료일이 이 값이면 아직 진행 중이다(레거시 관행) + private const string OpenEnded = "29991231"; + #endregion + + #region Methods + /// + /// 호출자가 시각을 주지 않았을 때 — 내원 자체에서 정한다 + /// (dtCommonLib.GetComAdpDtm, :3289-3346). + /// + /// DB 서버 시각 12자리. 단말 시계를 쓰지 않는다 + public static string Decide(VisitWindow visit, VisitEdge edge, string serverNow) + { + // 외래는 접수일시가 곧 적용일시다 — 자격 구간을 보지 않는다(:3344) + if (!visit.Inpatient) + { + return Clean(visit.AcceptedAt); + } + if (edge == VisitEdge.First) + { + // 자격 시작과 접수 중 늦은 쪽 — 자격이 접수 전에 시작했어도 내원 밖은 의미가 없다(:3334-3338) + return Later(Day(visit.CoverageFrom, "0000"), Clean(visit.AcceptedAt)); + } + if (Clean(visit.CoverageTo) == OpenEnded) + { + // 재원 중 — 지금이 기준이다. 단 입원 전환이 미래로 잡혀 있으면 접수 시각을 쓴다(:3310-3316) + var now = Clean(serverNow); + return Compare(now, Clean(visit.AcceptedAt)) <= 0 ? Clean(visit.AcceptedAt) : now; + } + // 자격 종료와 퇴원 중 이른 쪽 — 둘 중 먼저 끝난 것이 실제 끝이다(:3320-3328) + return Earlier(Day(visit.CoverageTo, "2359"), Clean(visit.LeftAt)); + } + + /// + /// 호출자가 시각을 준 경우 — 내원 구간 안으로 가둔다 + /// (bzPatientInfo.vb:441-511). + /// + /// 가두지 않으면 내원 밖 시각으로 자격·병실을 조회해 0행이 나온다. + /// 해석할 수 없는 값이면 빈 문자열을 돌려준다 — 호출부가 로 넘어가야 한다. + /// + public static string Clamp(string requested, VisitWindow visit, VisitEdge edge) + { + var value = Clean(requested); + var accepted = Clean(visit.AcceptedAt); + var left = Clean(visit.LeftAt); + + // 8자리(일자) — 그 날의 어느 끝을 쓸지 edge 가 정하고, 내원 경계가 더 안쪽이면 경계를 쓴다 + if (value.Length == 8) + { + if (edge == VisitEdge.Last) + { + var candidate = value + "2359"; + return left.Length >= 12 && Compare(left, candidate) <= 0 ? left : candidate; + } + var first = value + "0000"; + return accepted.Length >= 12 && Compare(accepted, first) >= 0 ? accepted : first; + } + + // 12자리 이상(일시) — 접수 전이면 접수로, 퇴원 후면 퇴원으로 당긴다 + if (value.Length >= 12) + { + var moment = value[..12]; + if (accepted.Length >= 12 && Compare(moment, accepted) < 0) + { + return accepted; + } + if (left.Length >= 12 && Compare(moment, left) > 0) + { + return left; + } + return moment; + } + + // 해석할 수 없다 — 레거시도 빈 문자열로 둔다(:505, :511) + return string.Empty; + } + + /// + /// 호출자 지정과 자동 판정을 한 번에 — 화면에서 쓰는 진입점. + /// 지정값이 해석되지 않으면 자동 판정으로 떨어진다. + /// + public static string Resolve(string? requested, VisitWindow visit, VisitEdge edge, string serverNow) + { + var clamped = Clamp(requested ?? string.Empty, visit, edge); + return clamped.Length > 0 ? clamped : Decide(visit, edge, serverNow); + } + + /// 8자리 일자에 시각을 붙인다 — 일자가 없으면 빈 문자열 + private static string Day(string date, string time) + { + var value = Clean(date); + return value.Length >= 8 ? value[..8] + time : string.Empty; + } + + /// 둘 중 늦은 쪽. 한쪽이 비면 다른 쪽 + private static string Later(string a, string b) + => a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) >= 0 ? a : b; + + /// 둘 중 이른 쪽. 한쪽이 비면 다른 쪽 + private static string Earlier(string a, string b) + => a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) <= 0 ? a : b; + + private static int Compare(string a, string b) => string.CompareOrdinal(a, b); + + /// CHAR 고정폭 잔여 공백을 흡수한다 — 이 컬럼들은 전부 고정폭이다 + private static string Clean(string? value) => (value ?? string.Empty).Trim(); + #endregion +} diff --git a/tests/SheetMe.Core.Tests/VisitMomentTests.cs b/tests/SheetMe.Core.Tests/VisitMomentTests.cs new file mode 100644 index 0000000..d2ccbde --- /dev/null +++ b/tests/SheetMe.Core.Tests/VisitMomentTests.cs @@ -0,0 +1,120 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 적용일시 판정 고정. +/// +/// 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 — 진료과·보험 자격·병동이 모두 +/// :AdpDtm BETWEEN StrDtm AND EndDtm 이다. 시각이 한 칸 어긋나면 조인이 0행이 되고 +/// 태그가 통째로 빈 값이 된다. 값이 틀리는 것이 아니라 아무것도 안 나온다 — +/// 그래서 사용자는 "미리보기가 고장났다"고 읽는다. +/// +[TestClass] +public sealed class VisitMomentTests +{ + #region Methods + private static VisitWindow Outpatient(string accepted) + => new(false, accepted, string.Empty, "20260101", "20260101"); + + private static VisitWindow Inpatient(string accepted, string left, string from, string to) + => new(true, accepted, left, from, to); + + [TestMethod] + public void 외래는_접수일시가_곧_적용일시다() + { + // 외래는 자격 구간을 보지 않는다(레거시 dtCommonLib.vb:3344) + Assert.AreEqual("202608180930", + VisitMoment.Decide(Outpatient("202608180930"), VisitEdge.Last, "202608181500")); + Assert.AreEqual("202608180930", + VisitMoment.Decide(Outpatient("202608180930"), VisitEdge.First, "202608181500")); + } + + [TestMethod] + public void 재원_중이면_지금이_기준이다() + { + // CoiEndDte = 29991231 이 재원 중 표식이다 + var admitted = Inpatient("202608100800", string.Empty, "20260810", "29991231"); + Assert.AreEqual("202608181500", VisitMoment.Decide(admitted, VisitEdge.Last, "202608181500")); + } + + [TestMethod] + public void 입원_전환이_미래면_접수_시각을_쓴다() + { + // 지금이 접수보다 이르면(미래 입원 전환) 지금을 쓸 수 없다 — 조인이 0행이 된다. + var future = Inpatient("202609010800", string.Empty, "20260901", "29991231"); + Assert.AreEqual("202609010800", VisitMoment.Decide(future, VisitEdge.Last, "202608181500")); + } + + [TestMethod] + public void 퇴원했으면_자격_종료와_퇴원_중_이른_쪽() + { + // 자격이 먼저 끝난 경우 + var coverageFirst = Inpatient("202608100800", "202608201200", "20260810", "20260815"); + Assert.AreEqual("202608152359", VisitMoment.Decide(coverageFirst, VisitEdge.Last, "202608181500")); + + // 퇴원이 먼저인 경우 + var leftFirst = Inpatient("202608100800", "202608121200", "20260810", "20260815"); + Assert.AreEqual("202608121200", VisitMoment.Decide(leftFirst, VisitEdge.Last, "202608181500")); + } + + [TestMethod] + public void First_는_자격_시작과_접수_중_늦은_쪽() + { + // 자격이 접수 전에 시작했어도 내원 밖은 의미가 없다 + var early = Inpatient("202608100800", "202608201200", "20260801", "20260820"); + Assert.AreEqual("202608100800", VisitMoment.Decide(early, VisitEdge.First, "202608181500")); + + // 자격이 접수 뒤에 시작한 경우 + var late = Inpatient("202608100800", "202608201200", "20260812", "20260820"); + Assert.AreEqual("202608120000", VisitMoment.Decide(late, VisitEdge.First, "202608181500")); + } + + [TestMethod] + public void 지정한_일자는_그_날의_끝을_쓰되_내원_경계를_넘지_않는다() + { + var visit = Inpatient("202608100800", "202608151030", "20260810", "20260815"); + // 퇴원이 그 날 23:59 보다 이르면 퇴원 시각 + Assert.AreEqual("202608151030", VisitMoment.Clamp("20260815", visit, VisitEdge.Last)); + // 퇴원 전날이면 그 날 끝 + Assert.AreEqual("202608122359", VisitMoment.Clamp("20260812", visit, VisitEdge.Last)); + // First 는 그 날 시작, 단 접수가 더 늦으면 접수 + Assert.AreEqual("202608100800", VisitMoment.Clamp("20260810", visit, VisitEdge.First)); + Assert.AreEqual("202608120000", VisitMoment.Clamp("20260812", visit, VisitEdge.First)); + } + + [TestMethod] + public void 지정한_일시는_내원_구간_안으로_가둔다() + { + var visit = Inpatient("202608100800", "202608151030", "20260810", "20260815"); + // 구간 안이면 그대로 + Assert.AreEqual("202608121200", VisitMoment.Clamp("202608121200", visit, VisitEdge.Last)); + // 접수 전이면 접수로 당긴다 — 안 당기면 자격·병실 조인이 0행이다 + Assert.AreEqual("202608100800", VisitMoment.Clamp("202608010000", visit, VisitEdge.Last)); + // 퇴원 후면 퇴원으로 당긴다 + Assert.AreEqual("202608151030", VisitMoment.Clamp("202609010000", visit, VisitEdge.Last)); + // 12자리를 넘겨도 앞 12자리만 + Assert.AreEqual("202608121200", VisitMoment.Clamp("20260812120059", visit, VisitEdge.Last)); + } + + [TestMethod] + public void 해석할_수_없는_값은_자동_판정으로_떨어진다() + { + var visit = Inpatient("202608100800", "202608151030", "20260810", "20260815"); + Assert.AreEqual(string.Empty, VisitMoment.Clamp("2026", visit, VisitEdge.Last)); + Assert.AreEqual(string.Empty, VisitMoment.Clamp(string.Empty, visit, VisitEdge.Last)); + // Resolve 는 그때 Decide 로 넘어간다 — 빈 값을 그대로 내보내면 조회가 전부 0행이 된다 + Assert.AreEqual("202608151030", VisitMoment.Resolve("2026", visit, VisitEdge.Last, "202608181500")); + Assert.AreEqual("202608121200", + VisitMoment.Resolve("202608121200", visit, VisitEdge.Last, "202608181500")); + } + + [TestMethod] + public void 고정폭_잔여_공백을_흡수한다() + { + // 이 컬럼들은 전부 CHAR 다. 공백이 붙은 채로 비교하면 사전순이 어긋난다. + var padded = new VisitWindow(false, "202608180930 ", " ", "20260101", "20260101"); + Assert.AreEqual("202608180930", VisitMoment.Decide(padded, VisitEdge.Last, "202608181500")); + } + #endregion +}