diff --git a/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs b/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs new file mode 100644 index 0000000..5c6c4ac --- /dev/null +++ b/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs @@ -0,0 +1,196 @@ +namespace SheetMe.Core.Catalog; + +/// 태그 값을 이 자리에서 만들 수 있는가 — 못 만든다면 왜 +public enum TagPreviewKind +{ + /// 서버 시각으로 만든다 + Clock, + + /// 로그인 사용자 정보로 만든다 + LoginUser, + + /// 환자·내원 문맥이 있어야 한다 — 서식생성기에는 없다 + PatientContext, + + /// 값이 아니라 목록(표)을 돌려준다 — 콤보를 채우는 용도 + Table, + + /// 파일서버 이미지 경로를 돌려준다 — 싸인·직인 + Image, + + /// 레거시가 항상 빈 값을 돌려준다 + Dead, + + /// 태그가 아니라 레거시 내부 함수 이름이다 + NotATag, +} + +/// +/// 태그의 값을 서식생성기에서 만들 수 있는지, 없다면 왜인지. +/// +/// 왜 표로 박는가. 판정 근거는 레거시 함수 본문이 환자·내원 객체를 참조하는지 여부다. +/// 그건 태그 이름에도 접두어에도 컨트롤 타입에도 들어 있지 않다 — +/// ETC_현재일자ETC_요양기관명칭_병원명 은 같은 접두어인데 한쪽만 만들 수 있다. +/// 이름 규칙으로 추정하면 절반만 맞는다. 그래서 실측 결과를 그대로 옮겨 적는다. +/// +/// 값보다 사유가 본체다. 실제로 값이 나오는 것은 384종 중 16종(4%)뿐이다. +/// 그러나 나머지에 대해 "왜 안 나오는가"는 지금 어디서도 얻을 수 없는 정보다 — +/// 실사용 3위인 ETC_요양기관명칭_병원명(303건)이 환자 내원을 타고 병원을 푼다는 것도, +/// 표를 돌려주는 태그와 죽은 태그와 태그가 아닌 내부 함수가 목록에 섞여 있다는 것도. +/// +public static class TagPreviewCatalog +{ + #region Member Fields + /// 서버 시각으로 만드는 것 — 값은 포맷 지시자 + private static readonly Dictionary ClockTags = new(StringComparer.Ordinal) + { + ["ETC_현재일자"] = ClockFormat.Date, + ["ETC_현재일시"] = ClockFormat.DateTime, + ["ETC_현재일시_MaskBox"] = ClockFormat.DateTimeMask, + ["ETC_현재년도"] = ClockFormat.Year, + ["ETC_현재시간"] = ClockFormat.Time, + ["ETC_현재일자_영문"] = ClockFormat.DateEnglish, + ["ETC_현재일시_1_년"] = ClockFormat.Year, + ["ETC_현재일시_2_월"] = ClockFormat.Month, + ["ETC_현재일시_3_일"] = ClockFormat.Day, + ["ETC_현재일시_4_몇시"] = ClockFormat.Hour, + ["ETC_현재일시_5_몇분"] = ClockFormat.Minute, + }; + + /// 로그인 사용자 정보로 만드는 것 — 값은 어느 항목인지 + private static readonly Dictionary UserTags = new(StringComparer.Ordinal) + { + ["ETC_로그인_사용자명"] = UserField.Name, + ["ETC_로그인_사용자ID"] = UserField.Id, + ["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName, + ["ETC_로그인_사용자연락처"] = UserField.MobilePhone, + ["ETC_로그인_원내연락처"] = UserField.OfficePhone, + }; + + /// + /// 못 만드는 것 중 환자 문맥 말고 다른 이유인 것들. + /// 여기 없는 것은 전부 다. + /// + private static readonly Dictionary Exceptions = new(StringComparer.Ordinal) + { + // 레거시가 항상 빈 문자열을 돌려준다(본문이 Return "") + ["OCM_수술일자_Refer"] = TagPreviewKind.Dead, + + // 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다. + // 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다. + ["퇴원약DT"] = TagPreviewKind.NotATag, + ["GetOprInfDT"] = TagPreviewKind.NotATag, + ["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag, + ["GetNurseEmrDT"] = TagPreviewKind.NotATag, + ["GetNurseShtCod"] = TagPreviewKind.NotATag, + }; + #endregion + + #region Methods + /// 이 태그의 값을 어떻게 얻는가 — 못 얻으면 그 이유 + public static TagPreviewKind KindOf(string tag) + { + if (ClockTags.ContainsKey(tag)) + { + return TagPreviewKind.Clock; + } + if (UserTags.ContainsKey(tag)) + { + return TagPreviewKind.LoginUser; + } + if (Exceptions.TryGetValue(tag, out var known)) + { + return known; + } + // 이름 끝으로 갈리는 두 갈래는 규칙이 확실하다 — 표에 나열하지 않는다 + if (tag.EndsWith("List", StringComparison.Ordinal) + || tag.EndsWith("InitialDatatable", StringComparison.Ordinal)) + { + return TagPreviewKind.Table; + } + if (tag.Contains("싸인", StringComparison.Ordinal) + || tag.Contains("서명", StringComparison.Ordinal) + || tag.Contains("직인", StringComparison.Ordinal) + || tag.Contains("로고", StringComparison.Ordinal)) + { + return TagPreviewKind.Image; + } + return TagPreviewKind.PatientContext; + } + + /// 값을 만들 수 있는 태그인가 + public static bool CanResolve(string tag) + => KindOf(tag) is TagPreviewKind.Clock or TagPreviewKind.LoginUser; + + /// 못 만드는 이유를 사람 말로 — 만들 수 있는 태그면 빈 문자열 + public static string ReasonText(string tag) => KindOf(tag) switch + { + TagPreviewKind.PatientContext => + "환자·내원 정보가 있어야 값이 나오는 태그입니다. 서식생성기에는 환자 문맥이 없습니다.", + TagPreviewKind.Table => + "값이 아니라 목록(표)을 돌려주는 태그입니다. 콤보를 채우는 용도라 한 줄로 보여 줄 값이 없습니다.", + TagPreviewKind.Image => + "파일서버에서 내려받은 이미지 경로를 돌려주는 태그입니다. 여기서는 글자만 보여 줍니다.", + TagPreviewKind.Dead => + "레거시에서 항상 빈 값을 돌려주는 태그입니다.", + TagPreviewKind.NotATag => + "레거시 내부 함수 이름입니다. 태그로 쓰면 값이 나오지 않습니다.", + _ => string.Empty, + }; + + /// 값 옆에 붙일 근거 한 줄 + public static string SourceText(string tag) => KindOf(tag) switch + { + TagPreviewKind.Clock => "서버 시각 기준", + TagPreviewKind.LoginUser => "로그인 사용자 기준", + _ => string.Empty, + }; + + /// 서버 시각에서 이 태그의 값을 만든다 — 시각 문자열은 yyyyMMddHHmm(12자리) + public static string FromClock(string tag, string minute12) + { + if (!ClockTags.TryGetValue(tag, out var format) || minute12.Length < 12) + { + return string.Empty; + } + var date = minute12[..8]; + return format switch + { + ClockFormat.Date => $"{date[..4]}-{date[4..6]}-{date[6..8]}", + ClockFormat.DateTime => $"{date[..4]}-{date[4..6]}-{date[6..8]} {minute12[8..10]}:{minute12[10..12]}", + ClockFormat.DateTimeMask => $"{date[..4]}-{date[4..6]}-{date[6..8]}{minute12[8..10]}:{minute12[10..12]}", + // 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치 + ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}", + ClockFormat.Year => date[..4], + ClockFormat.Month => date[4..6], + ClockFormat.Day => date[6..8], + ClockFormat.Hour => minute12[8..10], + ClockFormat.Minute => minute12[10..12], + ClockFormat.Time => $"{minute12[8..10]}:{minute12[10..12]}", + _ => string.Empty, + }; + } + + /// 로그인 사용자 정보에서 이 태그의 값을 만든다 + public static string FromUser(string tag, string id, string name, string mobile, string office) + { + if (!UserTags.TryGetValue(tag, out var field)) + { + return string.Empty; + } + return field switch + { + UserField.Name => name, + UserField.Id => id, + UserField.IdAndName => id.Length > 0 && name.Length > 0 ? $"{id}.{name}" : string.Empty, + UserField.MobilePhone => mobile, + UserField.OfficePhone => office, + _ => string.Empty, + }; + } + + private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, Month, Day, Hour, Minute, Time } + + private enum UserField { Name, Id, IdAndName, MobilePhone, OfficePhone } + #endregion +} diff --git a/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs b/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs new file mode 100644 index 0000000..c13607c --- /dev/null +++ b/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs @@ -0,0 +1,110 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 태그 값 판정 고정. +/// +/// 이 표가 틀리면 화면이 사용자에게 거짓말을 한다 — 값이 나오는 태그를 "안 된다"고 하거나, +/// 안 나오는 태그에 빈 칸을 띄운다. 판정 근거가 레거시 함수 본문이라 코드로는 다시 확인할 수 없어 +/// 여기가 유일한 안전망이다. +/// +[TestClass] +public sealed class TagPreviewCatalogTests +{ + #region Methods + [TestMethod] + public void 서버_시각_태그를_알아본다() + { + Assert.AreEqual(TagPreviewKind.Clock, TagPreviewCatalog.KindOf("ETC_현재일자")); + Assert.AreEqual(TagPreviewKind.Clock, TagPreviewCatalog.KindOf("ETC_현재일시")); + Assert.AreEqual(TagPreviewKind.Clock, TagPreviewCatalog.KindOf("ETC_현재일시_5_몇분")); + Assert.IsTrue(TagPreviewCatalog.CanResolve("ETC_현재일자")); + } + + [TestMethod] + public void 로그인_사용자_태그를_알아본다() + { + Assert.AreEqual(TagPreviewKind.LoginUser, TagPreviewCatalog.KindOf("ETC_로그인_사용자명")); + Assert.AreEqual(TagPreviewKind.LoginUser, TagPreviewCatalog.KindOf("ETC_로그인_원내연락처")); + Assert.IsTrue(TagPreviewCatalog.CanResolve("ETC_로그인_사용자ID")); + } + + [TestMethod] + public void 같은_접두어라도_환자_문맥이_필요한_것은_가른다() + { + // 실사용 3위(303건). 이름만 보면 기관 정보 같지만 환자 내원을 타고 병원을 푼다 — + // 접두어로 추정할 수 없다는 것이 이 표가 존재하는 이유다. + Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ETC_요양기관명칭_병원명")); + Assert.IsFalse(TagPreviewCatalog.CanResolve("ETC_요양기관명칭_병원명")); + Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("PAT_차트번호")); + } + + [TestMethod] + public void 표를_돌려주는_태그는_따로_가른다() + { + Assert.AreEqual(TagPreviewKind.Table, TagPreviewCatalog.KindOf("OCM_진료과List")); + Assert.AreEqual(TagPreviewKind.Table, TagPreviewCatalog.KindOf("PAT_병동InitialDatatable")); + } + + [TestMethod] + public void 이미지_경로_태그는_따로_가른다() + { + Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인")); + Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ETC_병원직인")); + } + + [TestMethod] + public void 죽은_태그와_태그가_아닌_것을_가른다() + { + Assert.AreEqual(TagPreviewKind.Dead, TagPreviewCatalog.KindOf("OCM_수술일자_Refer")); + Assert.AreEqual(TagPreviewKind.NotATag, TagPreviewCatalog.KindOf("GetNurseShtCod")); + Assert.AreEqual(TagPreviewKind.NotATag, TagPreviewCatalog.KindOf("퇴원약DT")); + } + + [TestMethod] + public void 못_만드는_것에는_이유가_붙고_만드는_것에는_안_붙는다() + { + Assert.AreNotEqual(string.Empty, TagPreviewCatalog.ReasonText("PAT_차트번호")); + Assert.AreNotEqual(string.Empty, TagPreviewCatalog.ReasonText("GetNurseShtCod")); + Assert.AreEqual(string.Empty, TagPreviewCatalog.ReasonText("ETC_현재일자"), + "값이 나오는 태그에 사유가 붙으면 화면에 둘이 같이 뜬다"); + Assert.AreEqual("서버 시각 기준", TagPreviewCatalog.SourceText("ETC_현재일자")); + Assert.AreEqual(string.Empty, TagPreviewCatalog.SourceText("PAT_차트번호")); + } + + [TestMethod] + public void 서버_시각을_레거시와_같은_형식으로_찍는다() + { + const string stamp = "202608141432"; + Assert.AreEqual("2026-08-14", TagPreviewCatalog.FromClock("ETC_현재일자", stamp)); + Assert.AreEqual("2026-08-14 14:32", TagPreviewCatalog.FromClock("ETC_현재일시", stamp)); + Assert.AreEqual("2026-08-1414:32", TagPreviewCatalog.FromClock("ETC_현재일시_MaskBox", stamp)); + // Data2Format_ENG 는 MM-DD-YYYY 로 재배치한다 + Assert.AreEqual("08-14-2026", TagPreviewCatalog.FromClock("ETC_현재일자_영문", stamp)); + Assert.AreEqual("2026", TagPreviewCatalog.FromClock("ETC_현재년도", stamp)); + Assert.AreEqual("08", TagPreviewCatalog.FromClock("ETC_현재일시_2_월", stamp)); + Assert.AreEqual("14:32", TagPreviewCatalog.FromClock("ETC_현재시간", stamp)); + } + + [TestMethod] + public void 시각_문자열이_모자라면_빈_값이다() + { + Assert.AreEqual(string.Empty, TagPreviewCatalog.FromClock("ETC_현재일자", "2026")); + Assert.AreEqual(string.Empty, TagPreviewCatalog.FromClock("PAT_차트번호", "202608141432")); + } + + [TestMethod] + public void 사용자_정보를_레거시와_같은_형식으로_잇는다() + { + Assert.AreEqual("홍길동", TagPreviewCatalog.FromUser("ETC_로그인_사용자명", "U01", "홍길동", "010", "1234")); + Assert.AreEqual("U01", TagPreviewCatalog.FromUser("ETC_로그인_사용자ID", "U01", "홍길동", "010", "1234")); + Assert.AreEqual("U01.홍길동", + TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", "홍길동", "010", "1234")); + Assert.AreEqual("010", TagPreviewCatalog.FromUser("ETC_로그인_사용자연락처", "U01", "홍길동", "010", "1234")); + // 한쪽이 비면 점만 남은 값을 만들지 않는다 + Assert.AreEqual(string.Empty, + TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", string.Empty, "010", "1234")); + } + #endregion +}