diff --git a/src/SheetMe.Core/Catalog/ITagValueResolver.cs b/src/SheetMe.Core/Catalog/ITagValueResolver.cs new file mode 100644 index 0000000..57b4e24 --- /dev/null +++ b/src/SheetMe.Core/Catalog/ITagValueResolver.cs @@ -0,0 +1,27 @@ +namespace SheetMe.Core.Catalog; + +/// 태그 하나를 값으로 바꾼 결과 +/// 값을 만들었는가 — false 면 는 사유다 +/// 값 또는 사유 +public readonly record struct TagValue(bool Resolved, string Text) +{ + public static readonly TagValue None = new(false, string.Empty); +} + +/// +/// 미리보기에서 데이터 태그를 값으로 바꾸는 주체. +/// +/// 왜 인터페이스인가. 지금 값을 만들 수 있는 것은 서버 시각·로그인 사용자 계열 +/// 16종(운영 사용 579건, 9.1%)뿐이다. 나머지 368종은 환자·내원 문맥이 있어야 한다. +/// 그 문맥이 생기면 같은 자리로 더 강한 해석기가 들어와야 하므로, +/// 값이 흐를 관을 지금 파 두고 해석기만 갈아 끼운다. +/// +/// 못 만든 것을 감추지 않는다. 레거시는 값을 못 가져온 컨트롤을 노란색으로 칠했다 +/// (ucLoadSheetBase.vb:2300-2308) — 그런 장치가 있다는 것 자체가 현장 실패가 흔하다는 방증이다. +/// 조용히 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다. 그래서 사유를 함께 돌려준다. +/// +public interface ITagValueResolver +{ + /// 이 태그의 값 — 못 만들면 가 false 이고 사유가 담긴다 + TagValue Resolve(string tag); +} diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index cadc0f2..662e2ca 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -32,6 +32,34 @@ public static class EditSmoke return total; } + /// + /// 종이에 실제로 그려진 글자를 모은다 — 값이 찍혔는지는 시각 트리에서 확인해야 한다. + /// VM 속성만 보면 템플릿이 그 값을 쓰는지 알 수 없다(배선과 결과는 다른 문제다). + /// + private static string CollectText(System.Windows.DependencyObject node) + { + var text = node is System.Windows.Controls.TextBlock block ? block.Text + "\n" : string.Empty; + var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(node); + for (var i = 0; i < count; i++) + { + text += CollectText(System.Windows.Media.VisualTreeHelper.GetChild(node, i)); + } + return text; + } + + /// + /// 검사용 해석기 — 실제 DB 없이 "값이 나오는 태그"와 "안 나오는 태그" 두 갈래를 만든다. + /// 진단은 접속 없이도 같은 답을 내야 하므로 여기서 세션 해석기를 쓰지 않는다. + /// + private sealed class StubTagResolver : SheetMe.Core.Catalog.ITagValueResolver + { + public SheetMe.Core.Catalog.TagValue Resolve(string tag) + => SheetMe.Core.Catalog.TagPreviewCatalog.CanResolve(tag) + ? new SheetMe.Core.Catalog.TagValue(true, "2026-08-18") + : new SheetMe.Core.Catalog.TagValue(false, + SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag)); + } + public static int Run(string reportPath) { var lines = new List(); @@ -378,6 +406,53 @@ public static class EditSmoke Check("미리보기: 컨트롤을 추가하면 창이 따라온다", liveAfterAdd > livePages, $"요소 {livePages} → {liveAfterAdd} (안 늘면 생성자에서 한 번만 그린 것)"); live.Close(); + + // 태그가 종이에서 값이 되는가. + // 지금까지 미리보기는 태그를 글자 그대로 찍었다 — PAT_이름 이 종이에 그렇게 인쇄됐다. + // 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 사유로 바뀌어야 한다 + // (빈칸이면 서식이 잘못된 줄 알고, 태그 이름 그대로면 값이 나온 줄 안다). + var tagged = new FormDocument(); + var tagPage = new FormPage { Width = 720, Height = 856 }; + tagged.Pages.Add(tagPage); + foreach (var (id, tag) in new[] { ("시각", "ETC_현재일자"), ("환자", "PAT_차트번호") }) + { + var field = new ControlElement + { + Type = "TextBox", Id = id, + Bounds = new LayoutRect { X = 40, Y = id == "시각" ? 40 : 80, W = 200, H = 20 }, + }; + field.Props.SetText("Text", tag); + field.Props.SetText("DataInterfaceTag", tag); + tagPage.Controls.Add(field); + } + var taggedVm = new DesignerViewModel(tagged); + + string PaperText(SheetMe.Core.Catalog.ITagValueResolver? resolver) + { + var visual = (System.Windows.FrameworkElement) + Services.PrintService.BuildPageVisual(taggedVm.Pages[0], true, resolver); + visual.Measure(new System.Windows.Size(720, 856)); + visual.Arrange(new Rect(0, 0, 720, 856)); + visual.UpdateLayout(); + return CollectText(visual); + } + + // 해석기가 없으면 지금까지와 같다 — 태그 이름 그대로 + var noResolver = PaperText(null); + Check("태그 값: 해석기가 없으면 태그 이름 그대로", + noResolver.Contains("PAT_차트번호", StringComparison.Ordinal) + && !noResolver.Contains("[", StringComparison.Ordinal), + noResolver); + + // 값이 나오는 태그는 값으로, 안 나오는 태그는 사유로 + var withResolver = PaperText(new StubTagResolver()); + Check("태그 값: 값이 나오는 태그는 값이 찍힌다", + withResolver.Contains("2026-08-18", StringComparison.Ordinal), withResolver); + Check("태그 값: 못 만드는 태그는 사유가 찍힌다", + withResolver.Contains("PAT_차트번호", StringComparison.Ordinal) + && withResolver.Contains("환자", StringComparison.Ordinal) + && withResolver.Contains("[", StringComparison.Ordinal), + withResolver); } // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 33f8460..d2cc0cd 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -58,7 +58,8 @@ public static class PrintService /// 두 눈이 필요해서 미리보기 창이 보기를 전환할 수 있게 열어 둔다. /// 인쇄는 언제나 true 다 — 인쇄는 인쇄다. /// - public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true) + public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true, + SheetMe.Core.Catalog.ITagValueResolver? tags = null) { var canvas = new Canvas { @@ -90,7 +91,7 @@ public static class PrintService } var presenter = new ContentPresenter { - Content = PrintProjection(control, printFilter), + Content = PrintProjection(control, printFilter, tags), Width = Math.Max(1, control.Width), Height = Math.Max(1, control.Height), }; @@ -119,11 +120,12 @@ public static class PrintService /// 자식 VM 은 새로 만들지 않고 원본을 그대로 담는다 — 해석된 글꼴·색이 유지된다. /// 캔버스가 쓰는 VM 은 한 글자도 건드리지 않는다. /// - private static object PrintProjection(ControlViewModel control, bool printFilter) + private static object PrintProjection(ControlViewModel control, bool printFilter, + SheetMe.Core.Catalog.ITagValueResolver? tags) { if (control is not ContainerViewModel container) { - return control; + return WithTagValue(control, tags); } ContainerViewModel? copy = container switch { @@ -143,7 +145,7 @@ public static class PrintService { continue; } - if (PrintProjection(child, printFilter) is ControlViewModel projected) + if (PrintProjection(child, printFilter, tags) is ControlViewModel projected) { copy.Children.Add(projected); } @@ -153,5 +155,38 @@ public static class PrintService copy.ResolveVisualContext(container.EffectiveFont, container.Foreground); return copy; } + + /// + /// 데이터 태그가 붙은 컨트롤을 값이 들어간 사본으로 바꾼다. 태그가 없거나 해석기가 없으면 원본. + /// + /// 왜 필요한가. 지금 미리보기는 태그를 글자 그대로 찍는다 — + /// PAT_이름 이 종이에 그렇게 인쇄된다. 값이 흐를 관이 아예 없었다. + /// + /// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다. + /// 타입이 같으니 템플릿이 그대로 잡히고, 캔버스가 쓰는 VM 과 모델은 건드리지 않는다. + /// + /// 못 만든 값은 감추지 않는다. 사유를 대괄호로 감싸 그린다 — + /// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다. + /// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다). + /// + private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags) + { + if (tags is null) + { + return control; + } + var tag = control.Model.Props.GetText("DataInterfaceTag"); + if (tag is not { Length: > 0 } || tag == "None") + { + return control; + } + var value = tags.Resolve(tag); + var text = value.Resolved ? value.Text + : value.Text.Length > 0 ? $"[{tag} — {value.Text}]" : $"[{tag}]"; + + var model = control.Model.Clone(); + model.Props.SetText("Text", text); + return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground); + } #endregion } diff --git a/src/SheetMe.Designer/Services/SessionTagResolver.cs b/src/SheetMe.Designer/Services/SessionTagResolver.cs new file mode 100644 index 0000000..7612a38 --- /dev/null +++ b/src/SheetMe.Designer/Services/SessionTagResolver.cs @@ -0,0 +1,67 @@ +using SheetMe.Core.Catalog; +using SheetMe.Data.Stores; + +namespace SheetMe.Designer.Services; + +/// +/// 지금 세션만으로 만들 수 있는 태그 값 — 서버 시각 계열 11종, 로그인 사용자 계열 5종. +/// +/// 이것이 오늘 가능한 전부다. 데이터 태그 384종 중 값이 나오는 것은 이 16종(운영 사용 579건, 9.1%)뿐이고 +/// 나머지는 환자·내원 문맥이 있어야 한다. 그 문맥이 생기면 이 클래스를 대체하는 것이 아니라 +/// 앞에 하나 더 붙인다 — 시각·사용자 태그는 그때도 여기서 나온다. +/// +/// DB 는 창당 한 번만 읽는다. 재료는 서버 시각 1회 + 사용자 1행 1회로 끝난다 +/// (). 태그마다 읽으면 태그 40개짜리 서식에 왕복 40회가 된다 — +/// 레거시가 그렇게 하고 있고, 그것을 그대로 옮기지 않기로 한 이유다. +/// +public sealed class SessionTagResolver : ITagValueResolver +{ + #region Member Fields + private readonly TagPreviewStore? store; + private TagPreviewContext? context; + private bool tried; + #endregion + + #region Constructors + public SessionTagResolver() + { + var connection = ConfigService.Current.ConnectionString; + store = connection.Length > 0 ? new TagPreviewStore(connection) : null; + } + #endregion + + #region Methods + public TagValue Resolve(string tag) + { + if (tag.Length == 0) + { + return TagValue.None; + } + if (!TagPreviewCatalog.CanResolve(tag)) + { + // 사유를 돌려준다 — 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다 + return new TagValue(false, TagPreviewCatalog.ReasonText(tag)); + } + if (store is null || !store.CanUseDb) + { + return new TagValue(false, "DB 에 접속되어 있지 않습니다"); + } + if (!tried) + { + tried = true; + context = store.Read(UserSession.Current.UidCod, UserSession.Current.UidNam); + } + if (context is not { } ready) + { + return new TagValue(false, "값을 읽지 못했습니다"); + } + var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock + ? TagPreviewCatalog.FromClock(tag, ready.Minute12) + : TagPreviewCatalog.FromUser(tag, ready.UidCod, ready.UidNam, + ready.MobilePhone, ready.OfficePhone); + return value.Length > 0 + ? new TagValue(true, value) + : new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다"); + } + #endregion +} diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index c524f1c..2d6a92c 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -26,6 +26,12 @@ public partial class PreviewWindow : Window private DesignerViewModel designer; private double zoom = 1.0; + + /// + /// 태그 값 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다. + /// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다. + /// + private readonly SessionTagResolver tags = new(); #endregion #region Constructors @@ -137,7 +143,7 @@ public partial class PreviewWindow : Window BorderBrush = System.Windows.Media.Brushes.LightGray, BorderThickness = new Thickness(1), Margin = new Thickness(0, 0, 0, 20), - Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true), + Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags), Width = page.WidthDip, Height = page.HeightDip, };