From 9bff3dd5f6b44adb721b43e22d7b025e5e57b196 Mon Sep 17 00:00:00 2001 From: Msystech Date: Wed, 19 Aug 2026 09:08:40 +0900 Subject: [PATCH] =?UTF-8?q?MDataTable=20=EC=9D=84=20=EB=AF=B8=EB=A6=AC?= =?UTF-8?q?=EB=B3=B4=EA=B8=B0=EC=97=90=EC=84=9C=20=EC=8B=A4=EC=A0=9C?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EB=A6=B0=EB=8B=A4=20=E2=80=94=20=ED=83=9C?= =?UTF-8?q?=EA=B7=B8=EB=A7=8C=20=EC=B1=84=EC=9A=B0=EB=A9=B4=20=EB=B0=98?= =?UTF-8?q?=EC=AF=A4=20=EB=B9=88=20=EA=B2=83=EC=9D=84=20=EB=AA=BB=20?= =?UTF-8?q?=EB=B3=B8=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "태그와 MDataTable도 환자 선택하면 해당 정보로 조회되는지 확인가능해야" 한다는 요구. 확인해 보니 미리보기가 DataTableViewModel 을 아예 건너뛰고 있었다(PrintService:87,143). 서식의 값 상당수가 태그가 아니라 이 관으로 오는데(운영 실측 Rows 형 1,624건) 그쪽은 미리보기에 존재하지 않았다. ## 레거시 구조를 먼저 확인했다 TK_PREVIEW 는 meLoadMode 를 Runtime 으로 바꾸고 TestPatientSetting() 으로 환자 선택 창을 띄운다(frmSheetDesigner.vb:452-462). 고르는 것만으로는 화면이 안 바뀐다 — moPatInfoBiz 는 필드에 담기기만 하고(:634-674), 서식을 다시 열어야 ucLoadSheetBase(moWrkInfoBiz, moPatInfoBiz, …, EN_LoadType.Edit) 로 로드되면서 그때 태그와 MDataTable 이 실행된다(:216). 탭마다 별도 인스턴스라 환자를 바꿔도 이미 열린 탭은 옛 환자를 계속 들고 있다. SheetMe 는 1단계다. UsePatient 가 해석기를 갈고 즉시 다시 그린다. 창이 하나뿐이라 잔상도 없다. 이 차이는 의도한 것이다. ## 치환 엔진(QuerySubstitution) clsMDataTable.ConvertQuery 를 옮겼다. 한 칸이라도 다르면 미리보기가 운영과 다른 SQL 을 돌린다 — "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고 사람이 확인했다고 믿고 넘어가므로 미리보기가 없는 것보다 나쁘다. 처음에 갈래 하나를 반대로 만들었다. 대문자 Item 처럼 정규식이 안 맞는 경우를 "토큰이 남는다"로 단정했는데, GetPropertyInfo 는 정규식 실패에 ""를 돌려주고(:118) 호출부가 그걸 빈 문자열로 치환한다(:66-70) — 즉 지워진다. 토큰이 남는 갈래는 접두어 불일치뿐이다(oBaseObj 가 Nothing 이라 Replace 를 안 한다). 소스를 읽어 고쳤다. 두 갈래를 섞으면 안 되는 이유는 하나는 ORA 구문오류가 되고 다른 하나는 조건이 사라진 SQL 이 조용히 도는 것이라서다. SQL 에서는 같아지는 것들도 사람에게는 갈라서 말한다 — Unknown(아직 안 옮긴 속성) / Empty(값이 빔) / NotAVariable(접두어 틀림). 셋은 고칠 곳이 전부 다르다. ## 짐작하지 않는 변수원(PatientQueryVariableSource) DataRow 접근형이 이 기능의 대부분을 실어 준다 — PatInfDR.item("아무컬럼") 은 SELECT * 결과 사전을 그대로 조회하면 되므로 내가 컬럼을 알 필요가 없다. 짐작할 것이 없으니 조용히 틀릴 일도 없다. 스칼라도 대부분 그 다섯 행의 컬럼 하나라, 컬럼 이름만 적고 있으면 값·없으면 모른다고 답한다. 존재 여부는 DB 가 판정한다. UDF 산출값(Age·Sex)은 넣지 않았다. ## 값이 안 나올 때를 위한 창 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄만 나온다. 그것으로는 쿼리가 틀렸는가·치환이 빈 값이 됐는가·이 환자에게 자료가 없는가를 못 가른다. 그래서 데이터소스 창이 치환을 마친 SQL을 그대로 보여 준다. 값이 안 나왔을 때 봐야 하는 것은 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 — 런타임이 빈 catch 로 삼켜 "빈칸"만 남았다. 진단 화면 표본도 실패 상태로 찍는다. 성공 화면만 회귀 대상으로 두면 정작 사람이 오래 들여다보는 화면이 검사에서 빠진다. ## 안전 - 실행은 OracleQueryWorkbench.Trial 을 그대로 쓴다 — SELECT/WITH 문이 이미 거기 있다. 문을 두 군데 두면 한쪽이 느슨해진다. - 치환 값은 레거시처럼 원문 그대로 박힌다(그래야 같은 SQL 이다). 따옴표가 섞인 값은 QuotedValues 로 드러내고 실행은 SELECT/WITH 문이 막는다. - 데이터소스당 한 번만 실행하고 캐시한다 — 배선 40개면 왕복 40회가 된다. - 환자를 바꾸면 러너를 새로 만든다. 재사용하면 태그만 바뀌고 표는 옛 환자가 남는데 그건 아무도 눈치채지 못한다. 서식을 갈아탈 때도 다시 만든다(옛 서식의 쿼리를 쓰게 된다). ## 아직 안 되는 것 Select 형의 필터(DataTable.Select 메모리 문법)는 옮기지 않았다. 무시하고 행 번호만 쓰면 다른 행의 값이 조용히 찍히므로, 값을 내지 않고 사유를 말한다. 운영 다수파인 Rows 형(76%)은 된다. ## 게이트 - dotnet test 323/323 (치환 판정 10건 신규) - --edit-smoke 실패 0 (배선 판정 7건 추가, 대조군 포함) - --db-patient ①~⑱ 전건 통과. ⑯ 치환한 SQL 이 실제로 1행을 뽑고, ⑱ 대조군은 환자 없이 같은 쿼리가 "WHERE ComNum = AND ... = ''" 로 깨진다 — 이 대조가 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다 - --dialog-shots FAIL 0 (08c-datasource-result 추가) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 - --db-smoke 1,271건 diff 0 Co-Authored-By: Claude Opus 5 --- src/SheetMe.Core/Catalog/ITagValueResolver.cs | 14 ++ src/SheetMe.Core/Catalog/QuerySubstitution.cs | 183 +++++++++++++++++ src/SheetMe.Designer/Diagnostics/DbSmoke.cs | 40 ++++ .../Diagnostics/DialogShots.cs | 30 +++ src/SheetMe.Designer/Diagnostics/EditSmoke.cs | 92 +++++++++ .../Services/MDataTableRunner.cs | 193 ++++++++++++++++++ .../Services/PatientQueryVariableSource.cs | 110 ++++++++++ src/SheetMe.Designer/Services/PrintService.cs | 57 ++++-- .../Views/DataSourceResultDialogView.xaml | 81 ++++++++ .../Views/DataSourceResultDialogView.xaml.cs | 144 +++++++++++++ src/SheetMe.Designer/Views/PreviewWindow.xaml | 7 + .../Views/PreviewWindow.xaml.cs | 41 +++- .../QuerySubstitutionTests.cs | 161 +++++++++++++++ 13 files changed, 1136 insertions(+), 17 deletions(-) create mode 100644 src/SheetMe.Core/Catalog/QuerySubstitution.cs create mode 100644 src/SheetMe.Designer/Services/MDataTableRunner.cs create mode 100644 src/SheetMe.Designer/Services/PatientQueryVariableSource.cs create mode 100644 src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml create mode 100644 src/SheetMe.Designer/Views/DataSourceResultDialogView.xaml.cs create mode 100644 tests/SheetMe.Core.Tests/QuerySubstitutionTests.cs 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 @@ + + + + + + +