diff --git a/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs new file mode 100644 index 0000000..b518f52 --- /dev/null +++ b/src/SheetMe.Core/Catalog/LegacyQueryVariableCatalog.cs @@ -0,0 +1,66 @@ +namespace SheetMe.Core.Catalog; + +/// 치환 변수 1건 — 이 SQL 에 그대로 삽입되는 문자열이다. +public sealed record QueryVariable(string Group, string Token, string Description); + +/// +/// MDataTable 쿼리의 치환 변수 카탈로그. +/// +/// 레거시 런타임 bzDesignSheetLoader.ConvertQuery 는 토큰에서 +/// sReserved.Substring(0, sReserved.LastIndexOf(".")) 를 잘라 +/// PatientInfo/SheetInfo/WorkInfo 객체의 GetType.FullName 과 +/// Select Case 완전일치로 비교한다. 즉 접두어는 클래스 전체 이름이어야 하며, +/// 축약형(<<PatientInfo.X>>)은 치환되지 않고 <<...>> 가 +/// SQL 에 리터럴로 남아 ORA 구문오류를 낸다(= 해당 데이터소스를 참조하는 컨트롤이 전부 공백). +/// +/// 접두어는 런타임 호스트의 속성 선언형으로 고정된다 +/// (ucLoadSheetBase.vb:7619-7631 — PatientInfo/WorkInfo 는 M.CMM.HISOperatingInfo, +/// SheetInfo 는 M.EMR.SheetLoadOperatingInfo). +/// +/// 속성명은 리플렉션(GetProperty(...).GetValue(...))으로 해석되므로 실존하는 공개 스칼라 +/// 속성만 등재한다. DataRow 접근형(...PatInfDR.item("컬럼"))은 위 LastIndexOf(".") 규칙이 +/// 접두어를 깨뜨려 이 경로에서 반드시 실패하므로 목록에 넣지 않는다. +/// +public static class LegacyQueryVariableCatalog +{ + #region Member Fields + private const string Patient = "M.CMM.HISOperatingInfo.bzPatientInfo"; + private const string Sheet = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo"; + private const string Work = "M.CMM.HISOperatingInfo.bzWorkInfo"; + + /// 그룹별 치환 변수 — 인스펙터 쿼리 편집기의 삽입 목록 + public static readonly IReadOnlyList All = new QueryVariable[] + { + new("환자", $"<<{Patient}.ChtNum>>", "차트번호"), + new("환자", $"<<{Patient}.ComNum>>", "내원번호"), + new("환자", $"<<{Patient}.ComNum_Refer>>", "참조 내원번호"), + new("환자", $"<<{Patient}.ComCvtCom>>", "전환 내원번호"), + new("환자", $"<<{Patient}.PatTyp>>", "입퇴원 구분"), + new("환자", $"<<{Patient}.AdpDtm>>", "접수일시"), + new("환자", $"<<{Patient}.OrderAdpDtm>>", "처방 접수일시"), + new("환자", $"<<{Patient}.Age>>", "나이"), + new("환자", $"<<{Patient}.AgeInMonth>>", "월령"), + new("환자", $"<<{Patient}.Sex>>", "성별"), + new("환자", $"<<{Patient}.PatMblPhn>>", "휴대전화"), + new("환자", $"<<{Patient}.ComDayCar>>", "낮병동 여부"), + new("환자", $"<<{Patient}.HipassYon>>", "하이패스 여부"), + new("환자", $"<<{Patient}.EmgKTSGrd>>", "응급 KTAS 등급"), + new("환자", $"<<{Patient}.OemNum1>>", "외부연동 번호1"), + new("환자", $"<<{Patient}.OemNum2>>", "외부연동 번호2"), + new("환자", $"<<{Patient}.OemVar1>>", "외부연동 값1"), + + new("서식", $"<<{Sheet}.ShtCod>>", "서식 코드"), + new("서식", $"<<{Sheet}.ShtNam>>", "서식명"), + new("서식", $"<<{Sheet}.EmrKey>>", "EMR 키(신규 작성 시 0)"), + new("서식", $"<<{Sheet}.AdpDtm>>", "작성일시"), + new("서식", $"<<{Sheet}.TemKey>>", "템플릿 키"), + + new("작업자", $"<<{Work}.WrkUid>>", "작성자 ID"), + new("작업자", $"<<{Work}.WrkDte>>", "작업일자(yyyyMMdd)"), + new("작업자", $"<<{Work}.WrkDtm>>", "작업일시"), + }; + + /// 런타임이 완전일치로 비교하는 접두어 3종 — 검증·진단용 + public static readonly IReadOnlyList Prefixes = new[] { Patient, Sheet, Work }; + #endregion +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index c98959a..4b288f6 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -384,7 +384,24 @@ public static class EditSmoke var fromJson = jsonSerializer.Read(json); Check("JSON 왕복 — XML 동일", serializer.Write(fromJson) == serializer.Write(designer.Document)); - // 22) 연속 Undo 로 빈 문서까지 + // 22) 쿼리 편집기 — XAML 로드/바인딩과 두 모드(SQL/상용구)를 실제로 생성해 확인 + var sqlEditor = new Views.QueryEditorWindow("스모크", "SELECT 1 FROM DUAL"); + var variableItems = (sqlEditor.FindName("VariableList") as System.Windows.Controls.ListBox)? + .ItemsSource?.Cast().Count() ?? 0; + Check("쿼리 편집기: 치환 변수 목록 바인딩", + variableItems == Core.Catalog.LegacyQueryVariableCatalog.All.Count, + $"바인딩 {variableItems}건 / 카탈로그 {Core.Catalog.LegacyQueryVariableCatalog.All.Count}건"); + Check("쿼리 편집기: 변수 패널 표시", + (sqlEditor.FindName("VariablePane") as UIElement)?.Visibility == Visibility.Visible); + sqlEditor.Close(); + + var wordEditor = new Views.QueryEditorWindow("상용구 수정", "문구", showVariables: false); + Check("상용구 모드: 변수 패널 숨김", + (wordEditor.FindName("VariablePane") as UIElement)?.Visibility == Visibility.Collapsed); + Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title); + wordEditor.Close(); + + // 23) 연속 Undo 로 빈 문서까지 var guard = 0; while (designer.Undo.CanUndo && guard++ < 80) { diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml index 5d89c04..4b6319b 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml @@ -1,11 +1,11 @@ - + @@ -16,8 +16,8 @@ - - + + @@ -30,12 +30,33 @@ - - + + - + + + + + + + + + + + + + + + + + + + diff --git a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs index 5e8d0e7..3ed9493 100644 --- a/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/QueryEditorWindow.xaml.cs @@ -1,54 +1,52 @@ +using System.ComponentModel; using System.Windows; using System.Windows.Controls; +using System.Windows.Data; +using SheetMe.Core.Catalog; namespace SheetMe.Designer.Views; /// /// 데이터소스(MDataTable) 쿼리 전용 편집기 — 큰 SQL 편집 영역 + 치환 변수 삽입. -/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery — <<PatientInfo/SheetInfo/WorkInfo.속성>> 리플렉션 치환. -/// 변수 목록 출처: [021]SheetLoadOperatingInfo 의 bzPatientInfo/bzSheetInfo/bzWorkInfo 공개 속성(스칼라만 큐레이션, 2026-07-16). +/// 치환 규칙 원본: [014]EMRLoader bzDesignSheetLoader.ConvertQuery. +/// 토큰 접두어는 클래스 전체 이름이어야 하며 런타임이 GetType.FullName 과 완전일치로 비교한다 +/// (ucLoadSheetBase.vb:7619-7631 이 형을 고정). 목록은 참조. +/// +/// 큰 다중행 편집 영역이 필요한 다른 용도(상용구 문구)에도 재사용한다 — +/// 그 경우 showVariables: false 로 SQL 변수 패널을 숨긴다. /// public partial class QueryEditorWindow : Window { - #region Member Fields - /// 치환 변수 목록 — 작성 시점 실제 값으로 치환됨 - private static readonly string[] Variables = - { - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - "<>", - }; - #endregion - #region Properties - /// 편집 결과 SQL — 확인 시 채워짐 + /// 편집 결과 텍스트 — 확인 시 채워짐 public string QueryText { get; private set; } = string.Empty; #endregion #region Constructors - public QueryEditorWindow(string ownerLabel, string initialQuery) + /// 제목에 붙일 대상 이름 + /// 초기 텍스트 + /// SQL 치환 변수 패널 표시 여부 — 상용구 등 SQL 이 아닌 용도에서는 false + public QueryEditorWindow(string ownerLabel, string initialQuery, bool showVariables = true) { InitializeComponent(); - Title = $"쿼리 편집 — {ownerLabel}"; SqlBox.Text = initialQuery; - VariableList.ItemsSource = Variables; + + if (showVariables) + { + Title = $"쿼리 편집 — {ownerLabel}"; + var view = new CollectionViewSource { Source = LegacyQueryVariableCatalog.All }; + view.GroupDescriptions.Add(new PropertyGroupDescription(nameof(QueryVariable.Group))); + VariableList.ItemsSource = view.View; + } + else + { + Title = ownerLabel; + HintText.Text = "내용을 편집한 뒤 확인을 누르세요."; + VariablePane.Visibility = Visibility.Collapsed; + SplitterColumn.Width = new GridLength(0); + VariableColumn.Width = new GridLength(0); + } + Loaded += (_, _) => { SqlBox.Focus(); @@ -63,13 +61,13 @@ public partial class QueryEditorWindow : Window private void OnInsertVariable(object sender, System.Windows.Input.MouseButtonEventArgs e) { - if (VariableList.SelectedItem is not string variable) + if (VariableList.SelectedItem is not QueryVariable variable) { return; } var caret = SqlBox.CaretIndex; - SqlBox.Text = SqlBox.Text.Insert(caret, variable); - SqlBox.CaretIndex = caret + variable.Length; + SqlBox.Text = SqlBox.Text.Insert(caret, variable.Token); + SqlBox.CaretIndex = caret + variable.Token.Length; SqlBox.Focus(); } diff --git a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs index 201c601..9001395 100644 --- a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs +++ b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs @@ -76,8 +76,8 @@ public partial class RecordWordDialogView : Window { return; } - var editor = new QueryEditorWindow($"상용구 수정", selected.Value) { Owner = this }; - // QueryEditorWindow 재사용(큰 편집 영역) — 변수 삽입은 무시해도 무해 + // QueryEditorWindow 재사용(큰 다중행 편집 영역) — SQL 이 아니므로 치환 변수 패널은 숨긴다 + var editor = new QueryEditorWindow("상용구 수정", selected.Value, showVariables: false) { Owner = this }; if (editor.ShowDialog() != true) { return; diff --git a/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs new file mode 100644 index 0000000..e3c17d9 --- /dev/null +++ b/tests/SheetMe.Core.Tests/LegacyQueryVariableCatalogTests.cs @@ -0,0 +1,74 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 쿼리 치환 변수 카탈로그 테스트 — 레거시 bzDesignSheetLoader.ConvertQuery 의 +/// "접두어를 GetType.FullName 과 완전일치 비교" 규칙을 시뮬레이션한다. +/// +[TestClass] +public sealed class LegacyQueryVariableCatalogTests +{ + /// 레거시 ConvertQuery 와 동일한 접두어 추출 — Substring(0, LastIndexOf(".")) + private static string PrefixOf(string token) + { + var inner = token[2..^2]; + return inner[..inner.LastIndexOf('.')]; + } + + /// 모든 토큰이 <<…>> 로 감싸이고 중복이 없어야 한다 + [TestMethod] + public void Tokens_AreWellFormedAndUnique() + { + var seen = new HashSet(StringComparer.Ordinal); + foreach (var variable in LegacyQueryVariableCatalog.All) + { + StringAssert.StartsWith(variable.Token, "<<", $"{variable.Token}: '<<' 로 시작해야 합니다."); + StringAssert.EndsWith(variable.Token, ">>", $"{variable.Token}: '>>' 로 끝나야 합니다."); + Assert.IsTrue(seen.Add(variable.Token), $"중복 토큰: {variable.Token}"); + Assert.IsFalse(string.IsNullOrWhiteSpace(variable.Description), $"{variable.Token}: 설명이 비어 있습니다."); + } + } + + /// + /// 레거시 완전일치 시뮬레이션 — 접두어가 런타임 비교 대상 3종 중 하나와 정확히 같아야 한다. + /// 축약형이나 DataRow 접근형(.item("컬럼"))이 섞이면 여기서 걸린다. + /// + [TestMethod] + public void EveryToken_ResolvesUnderLegacyExactMatchRule() + { + foreach (var variable in LegacyQueryVariableCatalog.All) + { + var prefix = PrefixOf(variable.Token); + CollectionAssert.Contains(LegacyQueryVariableCatalog.Prefixes.ToList(), prefix, + $"{variable.Token}: 접두어 '{prefix}' 가 런타임 비교 대상이 아닙니다 → 치환되지 않고 SQL 에 그대로 남습니다."); + } + } + + /// 접두어는 클래스 전체 이름이어야 한다 — 축약형 회귀 방지(이 버그가 실제로 있었다) + [TestMethod] + public void Prefixes_AreFullyQualifiedTypeNames() + { + foreach (var prefix in LegacyQueryVariableCatalog.Prefixes) + { + StringAssert.StartsWith(prefix, "M.", $"'{prefix}' 가 어셈블리 네임스페이스로 시작하지 않습니다."); + Assert.IsTrue(prefix.Count(c => c == '.') >= 2, $"'{prefix}' 가 클래스 전체 이름 형태가 아닙니다."); + } + } + + /// 운영에서 실제로 쓰이는 토큰이 목록에 있어야 한다(2026-08-11 census 근거) + [TestMethod] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.ComNum")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.OemNum1")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.OemNum2")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.OemVar1")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.ComNum_Refer")] + [DataRow("M.CMM.HISOperatingInfo.bzPatientInfo.AdpDtm")] + [DataRow("M.CMM.HISOperatingInfo.bzWorkInfo.WrkDte")] + public void ProductionTokens_ArePresent(string inner) + { + Assert.IsTrue(LegacyQueryVariableCatalog.All.Any(v => v.Token == $"<<{inner}>>"), + $"운영에서 쓰이는 토큰이 목록에 없습니다: <<{inner}>>"); + } +}