diff --git a/src/SheetMe.Core/Catalog/ITagValueResolver.cs b/src/SheetMe.Core/Catalog/ITagValueResolver.cs
index 57b4e24..6c3dccd 100644
--- a/src/SheetMe.Core/Catalog/ITagValueResolver.cs
+++ b/src/SheetMe.Core/Catalog/ITagValueResolver.cs
@@ -25,3 +25,17 @@ public interface ITagValueResolver
/// 이 태그의 값 — 못 만들면 가 false 이고 사유가 담긴다
TagValue Resolve(string tag);
}
+
+///
+/// DataTableField 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
+///
+/// 태그와 다른 관이다. 태그(DataInterfaceTag)는 함수 하나가 값을 만들지만,
+/// 이쪽은 서식이 들고 있는 SQL 을 실행해서 나온 표에서 행·컬럼을 지목한다.
+/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
+/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
+///
+public interface IDataFieldResolver
+{
+ /// 이 배선의 값 — 못 만들면 사유가 담긴다
+ TagValue Resolve(DataTableFieldSpec spec);
+}
diff --git a/src/SheetMe.Core/Catalog/QuerySubstitution.cs b/src/SheetMe.Core/Catalog/QuerySubstitution.cs
new file mode 100644
index 0000000..63a0985
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/QuerySubstitution.cs
@@ -0,0 +1,183 @@
+using System.Text;
+using System.Text.RegularExpressions;
+
+namespace SheetMe.Core.Catalog;
+
+///
+/// 치환 변수의 값을 내주는 쪽 — 미리보기는 환자 문맥이, 시험 실행은 표본이 맡는다.
+///
+public interface IQueryVariableSource
+{
+ ///
+ /// 스칼라 속성 값. null 은 "이 이름을 모른다", 빈 문자열은 "안다, 값이 비었다"다.
+ /// 둘은 SQL 에서 같게 처리되지만(레거시가 그렇다) 사람에게는 다르게 말해야 한다.
+ ///
+ string? Scalar(string className, string property);
+
+ /// DataRow 속성. null 은 그 행을 모른다는 뜻
+ IReadOnlyDictionary? Row(string className, string property);
+}
+
+/// 치환 결과 한 건 — 무엇이 무엇으로 바뀌었는가
+/// 원문 토큰(<<…>> 포함)
+/// 박힌 값
+/// 어떻게 판정됐는가
+public readonly record struct QuerySubstitutionHit(string Token, string Value, QuerySubstitutionKind Kind);
+
+/// 치환 판정
+public enum QuerySubstitutionKind
+{
+ /// 값이 있었다
+ Value,
+
+ /// 이름은 아는데 값이 비었다 — 레거시도 빈 문자열로 박는다
+ Empty,
+
+ /// 이름을 모른다(아직 안 옮긴 속성) — 레거시는 빈 문자열, 우리는 그 사실을 말한다
+ Unknown,
+
+ /// 접두어가 세 클래스 중 어느 것도 아니다 — 레거시는 토큰을 그대로 남긴다(ORA 구문오류)
+ NotAVariable,
+}
+
+/// 치환을 마친 SQL 과 그 내역
+public sealed record QuerySubstitutionResult(
+ string Sql, IReadOnlyList Hits)
+{
+ /// SQL 에 남은 토큰이 있는가 — 있으면 실행하면 ORA 구문오류다
+ public bool HasLeftover => Hits.Any(h => h.Kind == QuerySubstitutionKind.NotAVariable);
+
+ /// 아직 옮기지 않은 속성들 — 화면이 "왜 비었나"를 설명할 때 쓴다
+ public IReadOnlyList UnknownTokens
+ => Hits.Where(h => h.Kind == QuerySubstitutionKind.Unknown).Select(h => h.Token).Distinct().ToList();
+
+ ///
+ /// 따옴표가 섞여 들어간 값들 — 레거시는 값을 원문 그대로 박는다.
+ /// 환자 이름에 ' 가 있으면 SQL 이 깨지거나 문장이 바뀐다.
+ /// 막지는 않는다(레거시와 같은 SQL 을 만들어야 미리보기가 거짓말을 하지 않는다) —
+ /// 대신 보이게 한다. 실행 쪽은 SELECT/WITH 만 허용하는 문이 따로 있다.
+ ///
+ public IReadOnlyList QuotedValues
+ => Hits.Where(h => h.Value.Contains('\'') || h.Value.Contains(';'))
+ .Select(h => h.Token).Distinct().ToList();
+}
+
+///
+/// MDataTable 쿼리의 <<…>> 치환 — 레거시 clsMDataTable.ConvertQuery 를 옮긴 것
+/// ([003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb:24-71, :113).
+///
+/// 규약을 그대로 지킨다. 여기서 한 칸이라도 다르게 만들면 미리보기가 운영과 다른 SQL 을 돌린다 —
+/// "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다.
+///
+/// 토큰이 세 클래스 전체 이름 중 하나로 시작하는지 본다(StartsWith).
+/// 축약형은 치환되지 않고 토큰이 SQL 에 남는다.
+/// 나머지를 ^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$ 로 해석한다 —
+/// 스칼라와 DataRow 접근 2형태.
+/// item 은 소문자만이다(정규식이 대소문자를 구분한다).
+/// 값은 원문 그대로 박는다 — 따옴표를 붙여 주지 않는다.
+/// 값을 못 만들면 빈 문자열로 박는다(:67-71, :138). 조용히 사라진다.
+///
+///
+/// 순수 함수로 둔다. 갈래가 많고(스칼라/DataRow 2형태/미지원/접두어 불일치) 실DB 로는
+/// 한 갈래씩 확인할 수 없다.
+///
+public static class QuerySubstitution
+{
+ #region Member Fields
+ /// 런타임 호스트의 속성 선언형 — ucLoadSheetBase.vb:7619-7631
+ public const string PatientClass = "M.CMM.HISOperatingInfo.bzPatientInfo";
+ public const string WorkClass = "M.CMM.HISOperatingInfo.bzWorkInfo";
+ public const string SheetClass = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo";
+
+ /// 긴 것부터 본다 — 접두어가 서로 겹치면 짧은 쪽이 먼저 맞아 나머지 해석이 깨진다
+ private static readonly string[] Classes = { PatientClass, WorkClass, SheetClass };
+
+ /// 레거시와 같은 정규식. item 소문자, 경로 한 단계만
+ private static readonly Regex Member = new(
+ @"^([a-zA-Z_]\w*)(?:\.item\(""([^""]+)""\)|\(""([^""]+)""\))?$",
+ RegexOptions.Compiled | RegexOptions.CultureInvariant);
+ #endregion
+
+ #region Methods
+ /// 치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다
+ public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source)
+ {
+ var text = sql ?? string.Empty;
+ var hits = new List();
+ var output = new StringBuilder(text.Length);
+ var at = 0;
+
+ while (at < text.Length)
+ {
+ var open = text.IndexOf("<<", at, StringComparison.Ordinal);
+ if (open < 0)
+ {
+ output.Append(text, at, text.Length - at);
+ break;
+ }
+ var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal);
+ if (close < 0)
+ {
+ // 닫히지 않은 토큰 — 손대지 않는다. 레거시도 여기서 멈춘다
+ output.Append(text, at, text.Length - at);
+ break;
+ }
+
+ output.Append(text, at, open - at);
+ var token = text[open..(close + 2)];
+ var inner = text[(open + 2)..close];
+ var (value, kind) = Resolve(inner, source);
+ hits.Add(new QuerySubstitutionHit(token, value, kind));
+ // 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다.
+ // 조용히 지우면 조건이 사라진 SQL이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다.
+ output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value);
+ at = close + 2;
+ }
+
+ return new QuerySubstitutionResult(output.ToString(), hits);
+ }
+
+ /// 토큰 안쪽을 값으로
+ private static (string Value, QuerySubstitutionKind Kind) Resolve(string inner, IQueryVariableSource source)
+ {
+ var className = Array.Find(Classes, c => inner.StartsWith(c, StringComparison.Ordinal));
+ if (className is null)
+ {
+ return (string.Empty, QuerySubstitutionKind.NotAVariable);
+ }
+ var rest = inner[className.Length..].TrimStart('.');
+ var match = Member.Match(rest);
+ if (!match.Success)
+ {
+ // 접두어는 맞는데 경로가 규약 밖이다(중첩 접근 등) — 레거시는 빈 문자열로 박는다
+ return (string.Empty, QuerySubstitutionKind.Unknown);
+ }
+
+ var property = match.Groups[1].Value;
+ var column = match.Groups[2].Success ? match.Groups[2].Value
+ : match.Groups[3].Success ? match.Groups[3].Value
+ : null;
+
+ if (column is null)
+ {
+ var scalar = source.Scalar(className, property);
+ return scalar is null
+ ? (string.Empty, QuerySubstitutionKind.Unknown)
+ : (scalar, scalar.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
+ }
+
+ var row = source.Row(className, property);
+ if (row is null)
+ {
+ return (string.Empty, QuerySubstitutionKind.Unknown);
+ }
+ // 행은 있는데 그 컬럼이 없다 — 레거시는 예외를 먹고 빈 문자열로 박는다.
+ // "행이 없다"와 구분해야 한다: 전자는 서식의 컬럼명 오타, 후자는 아직 안 옮긴 것이다.
+ if (!row.TryGetValue(column, out var cell))
+ {
+ return (string.Empty, QuerySubstitutionKind.Empty);
+ }
+ return (cell, cell.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index adaecdc..2ed562f 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -2033,6 +2033,46 @@ public static class DbSmoke
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
+
+ // ── MDataTable 치환 + 실행 ──
+ // 태그와 다른 관이다. 여기까지 돌지 않으면 "미리보기는 되는데 실제 서식은
+ // 반쯤 빈" 상태를 확인할 수 없다. 실DB 로만 확인 가능한 것은
+ // 치환한 SQL 이 실제로 실행되는가이므로 그것만 본다.
+ var probeDoc = new SheetMe.Core.Models.FormDocument();
+ var probePage = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
+ probeDoc.Pages.Add(probePage);
+ var probeSource = new SheetMe.Core.Models.ControlElement
+ {
+ Type = "MDataTable", Id = "MDataTable1",
+ Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
+ };
+ // 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다
+ probeSource.Props.SetText("Query",
+ "SELECT TRIM(ComChtNum) CHT FROM P_ComInf"
+ + " WHERE ComNum = <>"
+ + " AND TRIM(ComChtNum) = '<>'");
+ probePage.Controls.Add(probeSource);
+
+ var runner = new Services.MDataTableRunner(probeDoc,
+ new Services.PatientQueryVariableSource(ctx, "SMOKE"));
+ var ran = runner.Run("MDataTable1");
+ Check("⑯ 치환한 SQL 이 실제로 실행된다",
+ ran.Ok && ran.Rows.Count == 1,
+ ran.Ok ? $"{ran.Rows.Count}행 (내원번호로 1행이어야 한다)" : ran.Error);
+ Check("⑰ 스칼라·DataRow 치환이 둘 다 값으로 박힌다",
+ !ran.Sql.Contains("<<", StringComparison.Ordinal)
+ && !ran.Sql.Contains("= '' ", StringComparison.Ordinal)
+ && !ran.Sql.EndsWith("= ''", StringComparison.Ordinal),
+ ran.Sql);
+
+ // 대조군 — 환자 없이 같은 쿼리. 치환이 빈 값이 되어 SQL 이 깨져야 한다.
+ // 이게 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다.
+ var blind = new Services.MDataTableRunner(probeDoc, null).Run("MDataTable1");
+ Check("⑱ (대조군) 환자 없이는 같은 쿼리가 값을 내지 못한다",
+ !blind.Ok || blind.Rows.Count != 1,
+ $"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
+ lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
+ lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
}
}
}
diff --git a/src/SheetMe.Designer/Diagnostics/DialogShots.cs b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
index d8675bf..ccaf171 100644
--- a/src/SheetMe.Designer/Diagnostics/DialogShots.cs
+++ b/src/SheetMe.Designer/Diagnostics/DialogShots.cs
@@ -152,6 +152,9 @@ public static class DialogShots
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
+ // 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
+ // 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
+ ("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")),
@@ -424,6 +427,33 @@ public static class DialogShots
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
};
+ ///
+ /// 데이터소스 표본 — 환자 없이 만든다. 그러면 치환이 전부 빈 값이 되어 SQL 이 깨지고,
+ /// 진단이 실패 화면을 찍는다. 그게 사람이 실제로 오래 보게 되는 화면이다.
+ /// DB 접속 여부와 무관하게 같은 그림이 나온다(치환 단계에서 이미 실패한다).
+ ///
+ private static Services.MDataTableRunner SampleRunner()
+ {
+ var document = new SheetMe.Core.Models.FormDocument();
+ var page = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
+ document.Pages.Add(page);
+ foreach (var (id, query) in new[]
+ {
+ ("MDataTable1", "SELECT * FROM P_ComInf WHERE ComNum = <>"),
+ ("MDataTable2", "SELECT 1 FROM DUAL"),
+ })
+ {
+ var source = new SheetMe.Core.Models.ControlElement
+ {
+ Type = "MDataTable", Id = id,
+ Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
+ };
+ source.Props.SetText("Query", query);
+ page.Controls.Add(source);
+ }
+ return new Services.MDataTableRunner(document, null);
+ }
+
private static List SampleVersions() => new()
{
new DesignVersionInfo(52443, "202608121030", "011825", false),
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index c25a23d..07b9b6e 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -60,6 +60,25 @@ public static class EditSmoke
SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag));
}
+ ///
+ /// 검사용 배선 해석기 — 표 한 장을 손으로 쥐고 네 갈래(정상·컬럼오타·없는소스·형식깨짐)를 만든다.
+ /// 실행기()를 쓰면 DB 접속 여부에 판정이 묶인다.
+ ///
+ private sealed class StubFieldResolver : SheetMe.Core.Catalog.IDataFieldResolver
+ {
+ public SheetMe.Core.Catalog.TagValue Resolve(SheetMe.Core.Catalog.DataTableFieldSpec spec)
+ {
+ if (spec.TableName != "MDataTable1")
+ {
+ return new SheetMe.Core.Catalog.TagValue(false,
+ $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
+ }
+ return spec.Field == "NAM"
+ ? new SheetMe.Core.Catalog.TagValue(true, "ㄱ")
+ : new SheetMe.Core.Catalog.TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다");
+ }
+ }
+
public static int Run(string reportPath)
{
var lines = new List();
@@ -533,6 +552,79 @@ public static class EditSmoke
// 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면
// "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다.
+ // ── DataTableField 배선이 종이에서 값이 되는가 ──
+ // 서식의 값 상당수는 태그가 아니라 이 관으로 온다(운영 실측 Rows 형 1,624건).
+ // 태그만 채워 보면 "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
+ var fieldDoc = new FormDocument();
+ var fieldPage = new FormPage { Width = 720, Height = 856 };
+ fieldDoc.Pages.Add(fieldPage);
+ var source = new ControlElement
+ {
+ Type = "MDataTable", Id = "MDataTable1",
+ Bounds = new LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
+ };
+ source.Props.SetText("Query", "SELECT 'ㄱ' NAM FROM DUAL");
+ fieldPage.Controls.Add(source);
+ foreach (var (id, wiring, y) in new[]
+ {
+ ("정상", "MDataTable1.Rows(0).Item(\"NAM\")", 40),
+ ("컬럼오타", "MDataTable1.Rows(0).Item(\"NAMX\")", 80),
+ ("없는소스", "MDataTable9.Rows(0).Item(\"NAM\")", 120),
+ ("형식깨짐", "MDataTable1.Rows(0)", 160),
+ })
+ {
+ var wired = new ControlElement
+ {
+ Type = "TextBox", Id = id,
+ Bounds = new LayoutRect { X = 40, Y = y, W = 300, H = 20 },
+ };
+ wired.Props.SetText("Text", id);
+ wired.Props.SetText("DataTableField", wiring);
+ fieldPage.Controls.Add(wired);
+ }
+ var fieldVm = new DesignerViewModel(fieldDoc);
+
+ string FieldPaper(SheetMe.Core.Catalog.IDataFieldResolver? resolver)
+ {
+ var visual = (System.Windows.FrameworkElement)
+ Services.PrintService.BuildPageVisual(fieldVm.Pages[0], true, null, resolver);
+ visual.Measure(new System.Windows.Size(720, 856));
+ visual.Arrange(new Rect(0, 0, 720, 856));
+ visual.UpdateLayout();
+ return CollectText(visual);
+ }
+
+ // 대조군 — 해석기가 없으면 지금까지와 같다(배선 문자열이 아니라 Text 가 그대로)
+ var noFields = FieldPaper(null);
+ Check("배선(대조군): 해석기가 없으면 종이가 그대로다",
+ noFields.Contains("정상", StringComparison.Ordinal)
+ && !noFields.Contains("[", StringComparison.Ordinal),
+ noFields);
+
+ // 표를 직접 쥐고 네 갈래를 한 번에 본다 — DB 없이 판정해야 진단이 접속에 안 묶인다
+ var stub = new StubFieldResolver();
+ var withFields = FieldPaper(stub);
+ Check("배선: 맞는 배선은 값이 찍힌다",
+ withFields.Contains("ㄱ", StringComparison.Ordinal), withFields);
+ Check("배선: 컬럼명이 틀리면 그 컬럼이 없다고 말한다",
+ withFields.Contains("NAMX", StringComparison.Ordinal)
+ && withFields.Contains("없습니다", StringComparison.Ordinal),
+ withFields);
+ Check("배선: 없는 데이터소스를 가리키면 이름을 말한다",
+ withFields.Contains("MDataTable9", StringComparison.Ordinal), withFields);
+ // 레거시 런타임은 형식 오류를 빈 catch 로 삼켜 아무 표시 없이 빈칸이 된다.
+ // 그 고장을 그대로 물려받으면 미리보기가 있어도 못 찾는다.
+ Check("배선: 형식이 깨지면 읽을 수 없다고 말한다",
+ withFields.Contains("읽을 수 없습니다", StringComparison.Ordinal), withFields);
+
+ // 러너가 서식에서 데이터소스를 모으는가 — 이름을 못 모으면 배선이 전부 '없는 소스'가 된다.
+ // 패널 안에 있는 것도 모아야 한다(운영 서식은 대개 패널 안에 넣는다).
+ var runner = new Services.MDataTableRunner(fieldDoc, null);
+ Check("배선: 서식에서 데이터소스 이름을 모은다",
+ runner.Names.Count == 1 && runner.Names[0] == "MDataTable1",
+ string.Join(", ", runner.Names));
+ Check("배선: 환자 없이 만든 러너는 문맥이 없다고 말한다", !runner.HasContext);
+
// ── 미리보기 창을 통해서 값이 흐르는가 ──
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
diff --git a/src/SheetMe.Designer/Services/MDataTableRunner.cs b/src/SheetMe.Designer/Services/MDataTableRunner.cs
new file mode 100644
index 0000000..0183368
--- /dev/null
+++ b/src/SheetMe.Designer/Services/MDataTableRunner.cs
@@ -0,0 +1,193 @@
+using SheetMe.Core.Catalog;
+using SheetMe.Core.Models;
+using SheetMe.Data.Stores;
+
+namespace SheetMe.Designer.Services;
+
+/// 한 MDataTable 의 실행 결과 — 또는 왜 실행하지 못했는지
+/// 데이터소스 컨트롤 이름(배선이 이 이름으로 찾는다)
+/// 치환을 마친 SQL — 사람이 눈으로 확인해야 하는 그 문장
+/// 결과 컬럼
+/// 결과 행
+/// 실패 사유. null 이면 성공
+/// 치환하지 못한 변수들 — 값이 빈 이유가 여기 있을 수 있다
+public sealed record MDataTableResult(
+ string Name, string Sql,
+ IReadOnlyList Columns, IReadOnlyList Rows,
+ string? Error, IReadOnlyList Unknown)
+{
+ public bool Ok => Error is null;
+}
+
+///
+/// 미리보기에서 MDataTable 을 실제로 돌린다 — 레거시 ucLoadSheetBase 가
+/// 서식을 로드할 때 하던 일이다.
+///
+/// 왜 필요한가. 서식의 값 상당수는 태그가 아니라 이 경로로 온다. 태그만 채워 보면
+/// "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
+///
+/// 실행은 읽기만. 을 그대로 쓴다 —
+/// SELECT/WITH 만 통과시키는 문이 이미 거기 있고, 이 접속은 운영에 붙을 수 있다.
+/// 문을 두 군데 두면 한쪽이 느슨해진다.
+///
+/// 치환 값이 SQL 에 원문 그대로 박힌다(레거시가 그렇다 — 그래야 같은 SQL 이 된다).
+/// 따옴표가 섞인 값은 로 드러내고
+/// 실행은 SELECT/WITH 문이 막는다.
+///
+/// 한 번만 돌린다. 컨트롤마다 돌리면 배선 40개짜리 서식에 왕복 40회가 된다 —
+/// 데이터소스 단위로 캐시한다. 환자를 바꾸면 러너를 새로 만든다(같은 러너를 재사용하면
+/// 옛 환자의 표가 남는다).
+///
+public sealed class MDataTableRunner : IDataFieldResolver
+{
+ #region Member Fields
+ private readonly IQueryVariableSource variables;
+ private readonly OracleQueryWorkbench? workbench;
+ private readonly Dictionary cache = new(StringComparer.OrdinalIgnoreCase);
+
+ /// 이름 → 그 데이터소스의 쿼리 원문
+ private readonly Dictionary queries = new(StringComparer.OrdinalIgnoreCase);
+ #endregion
+
+ #region Constructors
+ /// 데이터소스와 그 쿼리를 여기서 모은다
+ /// 치환 값을 내주는 쪽 — 환자를 안 골랐으면 null
+ public MDataTableRunner(FormDocument document, IQueryVariableSource? variables)
+ {
+ this.variables = variables ?? EmptySource.Instance;
+ var connection = ConfigService.Current.ConnectionString;
+ workbench = connection.Length > 0 ? new OracleQueryWorkbench(connection) : null;
+
+ foreach (var page in document.Pages)
+ {
+ Collect(page.Controls);
+ }
+ }
+ #endregion
+
+ #region Methods
+ /// 이름이 붙은 데이터소스들 — 화면이 결과를 목록으로 보여 줄 때 쓴다
+ public IReadOnlyList Names => queries.Keys.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
+
+ /// 환자 문맥이 붙어 있는가 — 없으면 치환이 전부 빈 값이 되어 결과가 무의미하다
+ public bool HasContext => !ReferenceEquals(variables, EmptySource.Instance);
+
+ /// 이 데이터소스를 돌린 결과 — 처음 물을 때 한 번만 실행한다
+ public MDataTableResult Run(string name)
+ {
+ if (cache.TryGetValue(name, out var cached))
+ {
+ return cached;
+ }
+ var result = Execute(name);
+ cache[name] = result;
+ return result;
+ }
+
+ /// 모두 돌린다 — 미리보기가 결과를 한꺼번에 보여 줄 때
+ public IReadOnlyList RunAll() => Names.Select(Run).ToList();
+
+ public TagValue Resolve(DataTableFieldSpec spec)
+ {
+ if (!queries.ContainsKey(spec.TableName))
+ {
+ // 배선이 없는 데이터소스를 가리킨다 — 레거시 런타임은 빈 catch 로 삼켜서
+ // 아무 표시 없이 빈칸이 된다. 여기서는 이름을 말한다.
+ return new TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
+ }
+ if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
+ {
+ // Select 필터는 DataTable.Select 의 메모리 필터 문법이다(SQL 이 아니다).
+ // 아직 옮기지 않았다 — 무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍힌다.
+ return new TagValue(false, $"Select 필터를 아직 옮기지 않았습니다: {spec.Filter}");
+ }
+
+ var run = Run(spec.TableName);
+ if (!run.Ok)
+ {
+ return new TagValue(false, run.Error ?? "조회에 실패했습니다");
+ }
+ if (spec.RowIndex >= run.Rows.Count)
+ {
+ return new TagValue(false, run.Rows.Count == 0
+ ? "조회 결과가 0행입니다"
+ : $"{spec.RowIndex}번 행이 없습니다(결과 {run.Rows.Count}행)");
+ }
+ var column = -1;
+ for (var i = 0; i < run.Columns.Count; i++)
+ {
+ if (string.Equals(run.Columns[i], spec.Field, StringComparison.OrdinalIgnoreCase))
+ {
+ column = i;
+ break;
+ }
+ }
+ if (column < 0)
+ {
+ return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"
+ + $" (있는 컬럼: {string.Join(", ", run.Columns.Take(8))})");
+ }
+ var cell = run.Rows[spec.RowIndex][column] ?? string.Empty;
+ return cell.Length > 0
+ ? new TagValue(true, cell)
+ : new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
+ }
+
+ private MDataTableResult Execute(string name)
+ {
+ var sqlRaw = queries.TryGetValue(name, out var q) ? q : string.Empty;
+ if (sqlRaw.Trim().Length == 0)
+ {
+ return new MDataTableResult(name, string.Empty, Array.Empty(),
+ Array.Empty(), "쿼리가 비어 있습니다", Array.Empty());
+ }
+ var converted = QuerySubstitution.Apply(sqlRaw, variables);
+ if (converted.HasLeftover)
+ {
+ // 접두어가 클래스 전체 이름이 아니면 토큰이 SQL 에 남는다 — 실행하면 ORA 구문오류다.
+ // 미리 말해 준다. 오라클 오류 문구보다 이쪽이 고칠 곳을 알려 준다.
+ return new MDataTableResult(name, converted.Sql, Array.Empty(), Array.Empty(),
+ "치환되지 않은 변수가 남아 있습니다(접두어가 클래스 전체 이름이어야 합니다): "
+ + string.Join(", ", converted.Hits
+ .Where(h => h.Kind == QuerySubstitutionKind.NotAVariable)
+ .Select(h => h.Token).Distinct()),
+ converted.UnknownTokens);
+ }
+ if (workbench is null)
+ {
+ return new MDataTableResult(name, converted.Sql, Array.Empty(),
+ Array.Empty(), "DB 에 접속되어 있지 않습니다", converted.UnknownTokens);
+ }
+
+ var trial = workbench.Trial(converted.Sql);
+ return new MDataTableResult(name, converted.Sql, trial.Columns, trial.Rows,
+ trial.Ok ? null : trial.Error ?? "조회에 실패했습니다", converted.UnknownTokens);
+ }
+
+ /// 데이터소스를 재귀로 모은다 — 패널·그룹박스 안에 있는 것도 배선 대상이다
+ private void Collect(IEnumerable controls)
+ {
+ foreach (var control in controls)
+ {
+ if (control.Type == "MDataTable")
+ {
+ queries[control.Id] = control.Props.GetText("Query") ?? string.Empty;
+ }
+ if (control.Children.Count > 0)
+ {
+ Collect(control.Children);
+ }
+ }
+ }
+
+ /// 환자를 안 골랐을 때 — 모든 변수를 "모른다"로 답한다
+ private sealed class EmptySource : IQueryVariableSource
+ {
+ public static readonly EmptySource Instance = new();
+
+ public string? Scalar(string className, string property) => null;
+
+ public IReadOnlyDictionary? Row(string className, string property) => null;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/PatientQueryVariableSource.cs b/src/SheetMe.Designer/Services/PatientQueryVariableSource.cs
new file mode 100644
index 0000000..e219c88
--- /dev/null
+++ b/src/SheetMe.Designer/Services/PatientQueryVariableSource.cs
@@ -0,0 +1,110 @@
+using SheetMe.Core.Catalog;
+using SheetMe.Data.Stores;
+
+namespace SheetMe.Designer.Services;
+
+///
+/// 환자 문맥을 MDataTable 쿼리의 치환 변수로 내준다 — 레거시가 bzPatientInfo 인스턴스를
+/// 넘기던 자리다.
+///
+/// DataRow 접근형이 이 기능의 대부분을 실어 준다.
+/// PatInfDR.item("아무컬럼") 은 의 다섯 사전을 그대로 조회하면
+/// 되고, 그 사전은 SELECT * 결과라 컬럼을 내가 미리 알 필요가 없다 —
+/// 짐작할 것이 없으니 조용히 틀릴 일도 없다.
+///
+/// 스칼라 속성도 대부분 그 다섯 행의 컬럼 하나다. 그래서 표에 컬럼 이름만 적고
+/// 있으면 값, 없으면 모른다고 답한다. 컬럼이 실제로 있는지는 DB 가 판정한다.
+///
+/// 산출값(Age·Sex 등 UDF 를 거치는 것)은 넣지 않았다 — 계산식을 짐작하면 값이 조용히 틀린다.
+/// 넣지 않은 것은 으로 드러난다.
+///
+public sealed class PatientQueryVariableSource : IQueryVariableSource
+{
+ #region Member Fields
+ /// 스칼라 속성 → (문맥 행, 컬럼). 컬럼 존재 여부는 DB 결과가 판정한다
+ private static readonly Dictionary ColumnScalars =
+ new(StringComparer.Ordinal)
+ {
+ ["ChtNum"] = ("ComInf", "ComChtNum"),
+ ["PatTyp"] = ("ComInf", "ComPatTyp"),
+ ["ComCvtCom"] = ("ComInf", "ComCvtCom"),
+ ["PatMblPhn"] = ("PatInf", "PatMblPhn"),
+ ["ComDayCar"] = ("ComInf", "ComDayCar"),
+ };
+
+ private readonly PatientContext context;
+ private readonly string uidCod;
+ #endregion
+
+ #region Constructors
+ public PatientQueryVariableSource(PatientContext context, string uidCod)
+ {
+ this.context = context;
+ this.uidCod = uidCod;
+ }
+ #endregion
+
+ #region Methods
+ public string? Scalar(string className, string property)
+ {
+ if (className == QuerySubstitution.WorkClass)
+ {
+ // 작업 정보는 로그인 사용자와 적용일시뿐이다 — 레거시도 이 둘이 실사용의 전부다
+ return property switch
+ {
+ "WrkUid" => uidCod,
+ "AdpDtm" => context.AdpDtm,
+ _ => null,
+ };
+ }
+ if (className != QuerySubstitution.PatientClass)
+ {
+ // 서식 정보(bzSheetInfo)는 아직 붙이지 않았다 — 모른다고 말한다
+ return null;
+ }
+ // 계산해서 들고 있는 값이 먼저다. 이 둘은 컬럼이 아니라 판정 결과다
+ // (적용일시는 VisitMoment 가 여섯 갈래로 정한다).
+ if (property == "ComNum")
+ {
+ return context.ComNum.ToString("F0");
+ }
+ if (property is "AdpDtm" or "OrderAdpDtm")
+ {
+ return context.AdpDtm;
+ }
+ if (!ColumnScalars.TryGetValue(property, out var map))
+ {
+ return null;
+ }
+ var row = RowOf(map.Row);
+ // 행 자체가 없으면(외래의 병실 등) 값이 빈 것이다 — 속성을 모르는 것과 다르다
+ return row.TryGetValue(map.Column, out var value) ? value : row.Count == 0 ? string.Empty : null;
+ }
+
+ public IReadOnlyDictionary? Row(string className, string property)
+ {
+ if (className != QuerySubstitution.PatientClass)
+ {
+ return null;
+ }
+ return property switch
+ {
+ "PatInfDR" => context.PatInf,
+ "ComInfDR" => context.ComInf,
+ "CodInfDR" => context.CodInf,
+ "CoiInfDR" => context.CoiInf,
+ "CowInfDR" => context.CowInf,
+ _ => null,
+ };
+ }
+
+ private IReadOnlyDictionary RowOf(string name) => name switch
+ {
+ "PatInf" => context.PatInf,
+ "ComInf" => context.ComInf,
+ "CodInf" => context.CodInf,
+ "CoiInf" => context.CoiInf,
+ _ => context.CowInf,
+ };
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs
index d2cc0cd..ce7afd4 100644
--- a/src/SheetMe.Designer/Services/PrintService.cs
+++ b/src/SheetMe.Designer/Services/PrintService.cs
@@ -59,7 +59,8 @@ public static class PrintService
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
///
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
- SheetMe.Core.Catalog.ITagValueResolver? tags = null)
+ SheetMe.Core.Catalog.ITagValueResolver? tags = null,
+ SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
{
var canvas = new Canvas
{
@@ -91,7 +92,7 @@ public static class PrintService
}
var presenter = new ContentPresenter
{
- Content = PrintProjection(control, printFilter, tags),
+ Content = PrintProjection(control, printFilter, tags, fields),
Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height),
};
@@ -121,11 +122,12 @@ public static class PrintService
/// 캔버스가 쓰는 VM 은 한 글자도 건드리지 않는다.
///
private static object PrintProjection(ControlViewModel control, bool printFilter,
- SheetMe.Core.Catalog.ITagValueResolver? tags)
+ SheetMe.Core.Catalog.ITagValueResolver? tags,
+ SheetMe.Core.Catalog.IDataFieldResolver? fields)
{
if (control is not ContainerViewModel container)
{
- return WithTagValue(control, tags);
+ return WithTagValue(control, tags, fields);
}
ContainerViewModel? copy = container switch
{
@@ -145,7 +147,7 @@ public static class PrintService
{
continue;
}
- if (PrintProjection(child, printFilter, tags) is ControlViewModel projected)
+ if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected)
{
copy.Children.Add(projected);
}
@@ -169,24 +171,51 @@ public static class PrintService
/// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다.
/// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다).
///
- private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags)
+ private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
+ SheetMe.Core.Catalog.IDataFieldResolver? fields)
{
- if (tags is null)
+ // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
+ // 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 정해져 있어야 하기 때문이다
+ // (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
+ var (label, value) = Bound(control, tags, fields);
+ if (label 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}]";
+ : value.Text.Length > 0 ? $"[{label} — {value.Text}]" : $"[{label}]";
var model = control.Model.Clone();
model.Props.SetText("Text", text);
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
+
+ ///
+ /// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
+ ///
+ /// DataTableField 는 형식을 어기면 레거시 런타임이 빈 catch 로 삼켜
+ /// 아무 표시 없이 빈칸이 된다(ucLoadSheetBase.DataTableBinding_). 여기서는 파싱 실패도 말한다 —
+ /// 디자이너에서 정상으로 보이고 임상 화면에서만 빈칸이 되는 것이 이 배선의 고질적 고장이다.
+ ///
+ private static (string? Label, SheetMe.Core.Catalog.TagValue Value) Bound(
+ ControlViewModel control,
+ SheetMe.Core.Catalog.ITagValueResolver? tags,
+ SheetMe.Core.Catalog.IDataFieldResolver? fields)
+ {
+ var tag = control.Model.Props.GetText("DataInterfaceTag");
+ if (tags is not null && tag is { Length: > 0 } && tag != "None")
+ {
+ return (tag, tags.Resolve(tag));
+ }
+ var wiring = control.Model.Props.GetText("DataTableField");
+ if (fields is null || wiring is not { Length: > 0 })
+ {
+ return (null, SheetMe.Core.Catalog.TagValue.None);
+ }
+ var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring);
+ return spec is null
+ ? (wiring, new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다"))
+ : ($"{spec.TableName}.{spec.Field}", fields.Resolve(spec));
+ }
#endregion
}
diff --git a/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml b/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml
new file mode 100644
index 0000000..569c476
--- /dev/null
+++ b/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml
@@ -0,0 +1,81 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml.cs b/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml.cs
new file mode 100644
index 0000000..e7d61ad
--- /dev/null
+++ b/src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml.cs
@@ -0,0 +1,144 @@
+using System.Data;
+using System.Windows;
+using System.Windows.Controls;
+using SheetMe.Designer.Services;
+
+namespace SheetMe.Designer.Views;
+
+///
+/// MDataTable 조회 결과를 펼쳐 본다.
+///
+/// 왜 이 창이 필요한가. 종이에는 [MDataTable1.ALGYON — 조회 결과가 0행입니다]처럼
+/// 한 줄만 나온다. 그것으로는 세 가지를 가릴 수 없다 —
+/// 쿼리 자체가 틀렸는가, 치환이 빈 값이 되어 조건이 사라졌는가, 이 환자에게 정말 자료가 없는가.
+/// 셋은 고칠 곳이 전부 다르다.
+///
+/// 그래서 치환을 마친 SQL을 그대로 보여 준다. 값이 안 나왔을 때 사람이 봐야 하는 것은
+/// 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 —
+/// 런타임이 빈 catch 로 삼켜서 현장에서 "빈칸"만 남았다.
+///
+public partial class DataSourceResultDialogView : Window
+{
+ #region Nested Types
+ /// 목록 한 줄 — 실패한 것을 눌러 보지 않고도 알아야 한다
+ private sealed record Row(string Name, bool Ok, string Suffix, MDataTableResult Source)
+ {
+ public string Mark => Ok ? "✓" : "✕";
+ }
+ #endregion
+
+ #region Constructors
+ /// 진단(--dialog-shots)이 직접 만든다 — 앱 코드는 ShowFor 를 쓸 것
+ internal DataSourceResultDialogView(MDataTableRunner runner)
+ {
+ InitializeComponent();
+
+ var results = runner.RunAll();
+ var rows = results.Select(r => new Row(
+ r.Name, r.Ok,
+ r.Ok ? $"{r.Rows.Count}행" : "실패",
+ r)).ToList();
+ SourceList.ItemsSource = rows;
+
+ HeaderText.Text = runner.HasContext
+ ? $"데이터소스 {rows.Count}개 · 실패 {rows.Count(r => !r.Ok)}개."
+ + " 최대 20행까지만 읽습니다(읽기 전용 · SELECT/WITH 만 실행)."
+ : "환자를 고르지 않았습니다 — 치환 변수가 전부 빈 값이 되어 결과가 실제와 다릅니다."
+ + " 미리보기 창에서 '환자 선택'을 먼저 하세요.";
+
+ if (rows.Count > 0)
+ {
+ SourceList.SelectedIndex = 0;
+ }
+ }
+ #endregion
+
+ #region Methods
+ public static void ShowFor(MDataTableRunner runner, Window? owner)
+ => new DataSourceResultDialogView(runner) { Owner = owner }.ShowDialog();
+
+ private void OnSourceChanged(object sender, SelectionChangedEventArgs e)
+ {
+ if (SourceList.SelectedItem is not Row row)
+ {
+ return;
+ }
+ var result = row.Source;
+ SqlBox.Text = result.Sql;
+
+ var notes = new List();
+ if (result.Error is { Length: > 0 } error)
+ {
+ notes.Add("실패: " + error);
+ }
+ else
+ {
+ notes.Add($"{result.Rows.Count}행 · 컬럼 {result.Columns.Count}개");
+ }
+ if (result.Unknown.Count > 0)
+ {
+ // 여기가 가장 흔한 원인이다 — 아직 안 옮긴 변수가 빈 문자열로 박혀 조건이 사라진다.
+ // SQL 만 봐서는 "원래 그런 조건인지" 알 수 없으므로 따로 말한다.
+ notes.Add("아직 옮기지 않은 변수가 빈 값으로 들어갔습니다: "
+ + string.Join(", ", result.Unknown));
+ }
+ StatusText.Text = string.Join(" · ", notes);
+ StatusText.Foreground = (System.Windows.Media.Brush)FindResource(
+ result.Ok && result.Unknown.Count == 0 ? "B.Muted" : "B.Danger");
+
+ ResultGrid.ItemsSource = ToTable(result)?.DefaultView;
+ }
+
+ ///
+ /// 결과를 DataGrid 가 받을 표로. 컬럼명이 중복되거나 비면 DataTable 이 예외를 낸다 —
+ /// 운영 쿼리에는 별칭 없는 표현식(COUNT(*) 등)이 흔하므로 여기서 이름을 만들어 준다.
+ ///
+ private static DataTable? ToTable(MDataTableResult result)
+ {
+ if (result.Columns.Count == 0)
+ {
+ return null;
+ }
+ var table = new DataTable();
+ var used = new HashSet(StringComparer.OrdinalIgnoreCase);
+ foreach (var name in result.Columns)
+ {
+ var candidate = name.Length > 0 ? name : "컬럼";
+ var unique = candidate;
+ var n = 2;
+ while (!used.Add(unique))
+ {
+ unique = $"{candidate}_{n++}";
+ }
+ table.Columns.Add(unique, typeof(string));
+ }
+ foreach (var cells in result.Rows)
+ {
+ var values = new object[table.Columns.Count];
+ for (var i = 0; i < values.Length; i++)
+ {
+ values[i] = i < cells.Length ? cells[i] ?? string.Empty : string.Empty;
+ }
+ table.Rows.Add(values);
+ }
+ return table;
+ }
+
+ private void OnCopySql(object sender, RoutedEventArgs e)
+ {
+ if (SqlBox.Text.Length == 0)
+ {
+ return;
+ }
+ try
+ {
+ Clipboard.SetText(SqlBox.Text);
+ }
+ catch (Exception ex)
+ {
+ // 클립보드는 다른 프로세스가 잡고 있으면 실패한다 — 조용히 넘기면 복사된 줄 안다
+ DialogService.Notify(DialogKind.Warning, "복사", "클립보드에 넣지 못했습니다.", ex.Message, this);
+ }
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml b/src/SheetMe.Designer/Views/PreviewWindow.xaml
index 4663819..ccd5274 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml
@@ -30,6 +30,13 @@
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
+
+
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
index 67f2102..aeda793 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
@@ -39,6 +39,15 @@ public partial class PreviewWindow : Window
/// 화면이 실제와 다른 것을 보여 준다.
///
private SheetMe.Core.Catalog.ITagValueResolver tags;
+
+ ///
+ /// MDataTable 실행기 — DataTableField 배선의 값을 낸다.
+ /// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
+ ///
+ private MDataTableRunner? fields;
+
+ /// 지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다
+ private SheetMe.Data.Stores.PatientContext? patient;
#endregion
#region Constructors
@@ -61,7 +70,9 @@ public partial class PreviewWindow : Window
}
};
Subscribe();
- BuildPages();
+ // 환자 없이도 러너를 만든다 — 데이터소스가 몇 개 있는지는 지금 알 수 있어야 하고,
+ // 그래야 '데이터소스' 버튼이 처음부터 맞는 상태로 뜬다.
+ UsePatient(null, string.Empty);
}
#endregion
@@ -97,7 +108,9 @@ public partial class PreviewWindow : Window
Unsubscribe();
designer = next;
Subscribe();
- BuildPages();
+ // 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 옛 서식의 쿼리로
+ // 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
+ UsePatient(patient, PatientText.Text);
}
/// 페이지·컨트롤 추가/삭제는 즉시 따라간다
@@ -151,7 +164,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, tags),
+ Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
Width = page.WidthDip,
Height = page.HeightDip,
};
@@ -192,12 +205,34 @@ public partial class PreviewWindow : Window
///
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
+ patient = picked;
tags = picked is null ? session : new PatientTagResolver(picked, session);
+ // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
+ // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
+ fields = new MDataTableRunner(designer.Document,
+ picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null;
+ DataSourceButton.IsEnabled = fields.Names.Count > 0;
BuildPages();
}
+ ///
+ /// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
+ ///
+ /// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
+ /// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
+ /// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
+ ///
+ private void OnShowDataSources(object sender, RoutedEventArgs e)
+ {
+ if (fields is null)
+ {
+ return;
+ }
+ DataSourceResultDialogView.ShowFor(fields, this);
+ }
+
/// 보기 전환 — 인쇄 기준 / 화면 그대로
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
diff --git a/tests/SheetMe.Core.Tests/QuerySubstitutionTests.cs b/tests/SheetMe.Core.Tests/QuerySubstitutionTests.cs
new file mode 100644
index 0000000..16316c6
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/QuerySubstitutionTests.cs
@@ -0,0 +1,161 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// MDataTable 쿼리 치환 고정 — 레거시 clsMDataTable.ConvertQuery 와 같은 SQL 이 나와야 한다.
+///
+/// 한 칸이라도 다르면 미리보기가 운영과 다른 SQL을 돌린다. 그러면 "미리보기에서는
+/// 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다 —
+/// 사람이 확인했다고 믿고 넘어간다.
+///
+[TestClass]
+public sealed class QuerySubstitutionTests
+{
+ #region Nested Types
+ private sealed class Source : IQueryVariableSource
+ {
+ public string? Scalar(string className, string property) => (className, property) switch
+ {
+ (QuerySubstitution.PatientClass, "ChtNum") => "0012345",
+ (QuerySubstitution.PatientClass, "ComNum") => "1566794",
+ (QuerySubstitution.PatientClass, "AdpDtm") => "202608181500",
+ (QuerySubstitution.PatientClass, "PatTyp") => "O",
+ // 안다, 값이 비었다
+ (QuerySubstitution.PatientClass, "EmgKTSGrd") => string.Empty,
+ (QuerySubstitution.WorkClass, "WrkUid") => "011825",
+ _ => null,
+ };
+
+ public IReadOnlyDictionary? Row(string className, string property)
+ => (className, property) switch
+ {
+ (QuerySubstitution.PatientClass, "PatInfDR") =>
+ new Dictionary(StringComparer.OrdinalIgnoreCase)
+ {
+ ["PatNam"] = "가상환자", ["PatResNum"] = "8801011234567", ["PatMblPhn"] = string.Empty,
+ },
+ _ => null,
+ };
+ }
+ #endregion
+
+ #region Methods
+ private static QuerySubstitutionResult Run(string sql) => QuerySubstitution.Apply(sql, new Source());
+
+ [TestMethod]
+ public void 스칼라를_원문_그대로_박는다()
+ {
+ // 따옴표를 붙여 주지 않는다 — SQL 쪽에서 감싸야 한다(clsMDataTable.vb:113)
+ var r = Run("SELECT * FROM P_ComInf WHERE ComNum = <>");
+ Assert.AreEqual("SELECT * FROM P_ComInf WHERE ComNum = 1566794", r.Sql);
+ Assert.AreEqual(QuerySubstitutionKind.Value, r.Hits[0].Kind);
+ }
+
+ [TestMethod]
+ public void DataRow_두_형태를_모두_받는다()
+ {
+ var withItem = Run("X <>");
+ Assert.AreEqual("X 가상환자", withItem.Sql);
+
+ // 괄호만 쓰는 형태도 정규식이 받는다
+ var bare = Run("X <>");
+ Assert.AreEqual("X 가상환자", bare.Sql);
+ }
+
+ [TestMethod]
+ public void item_은_대문자면_값이_되지_않고_지워진다()
+ {
+ // 정규식이 대소문자를 구분한다(clsMDataTable.vb:112). 이걸 관대하게 만들면
+ // 레거시에서 안 되는 서식이 미리보기에서만 되고, 현장에서 빈칸으로 나온다.
+ //
+ // 지워지는 것이 맞다 — GetPropertyInfo 가 정규식 실패에 ""를 돌려주고(:118)
+ // 호출부가 그것을 빈 문자열로 치환한다(:66-70). 접두어 불일치와는 다른 갈래다:
+ // 그쪽은 oBaseObj 가 Nothing 이라 Replace 를 아예 하지 않아 토큰이 남는다.
+ var r = Run("X <>");
+ Assert.AreEqual("X ", r.Sql);
+ Assert.AreEqual(QuerySubstitutionKind.Unknown, r.Hits[0].Kind,
+ "SQL 에서는 사라지지만 사람에게는 '규약 밖 경로'라고 말해야 한다");
+ Assert.AreEqual(1, r.UnknownTokens.Count);
+ }
+
+ [TestMethod]
+ public void 축약형_접두어는_토큰이_그대로_남는다()
+ {
+ // 레거시는 클래스 전체 이름으로만 StartsWith 한다 — 축약형은 SQL 에 남아 ORA 구문오류가 된다.
+ // 조용히 지우면 조건이 사라진 SQL 이 돌아 엉뚱한 행이 나온다. 남기는 쪽이 안전하다.
+ var r = Run("SELECT 1 FROM DUAL WHERE X = <>");
+ StringAssert.Contains(r.Sql, "<>");
+ Assert.IsTrue(r.HasLeftover);
+ Assert.AreEqual(QuerySubstitutionKind.NotAVariable, r.Hits[0].Kind);
+ }
+
+ [TestMethod]
+ public void 모르는_속성과_빈_값을_구분한다()
+ {
+ // SQL 에서는 둘 다 빈 문자열이다(레거시와 같게) — 사람에게는 달라야 한다.
+ var unknown = Run("A<>B");
+ Assert.AreEqual("AB", unknown.Sql);
+ Assert.AreEqual(QuerySubstitutionKind.Unknown, unknown.Hits[0].Kind);
+ Assert.AreEqual(1, unknown.UnknownTokens.Count);
+
+ var empty = Run("A<>B");
+ Assert.AreEqual("AB", empty.Sql);
+ Assert.AreEqual(QuerySubstitutionKind.Empty, empty.Hits[0].Kind);
+ Assert.AreEqual(0, empty.UnknownTokens.Count);
+ }
+
+ [TestMethod]
+ public void 행은_있고_컬럼이_없으면_오타로_본다()
+ {
+ // 이 경우는 서식이 컬럼명을 틀린 것이다 — 아직 안 옮긴 것과 구분해야 고칠 곳을 안다.
+ var r = Run("A<>B");
+ Assert.AreEqual("AB", r.Sql);
+ Assert.AreEqual(QuerySubstitutionKind.Empty, r.Hits[0].Kind);
+ }
+
+ [TestMethod]
+ public void 여러_토큰과_사이_글자를_보존한다()
+ {
+ var r = Run("WHERE A = '<>'"
+ + " AND B = <>"
+ + " AND C = '<>'");
+ Assert.AreEqual("WHERE A = 'O' AND B = 1566794 AND C = '011825'", r.Sql);
+ Assert.AreEqual(3, r.Hits.Count);
+ }
+
+ [TestMethod]
+ public void 닫히지_않은_토큰은_손대지_않는다()
+ {
+ var r = Run("SELECT 1 FROM DUAL WHERE X = < "O'Brien";
+
+ public IReadOnlyDictionary? Row(string className, string property) => null;
+ }
+ #endregion
+}