MDataTable 을 미리보기에서 실제로 돌린다 — 태그만 채우면 반쯤 빈 것을 못 본다
"태그와 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) — 즉 지워진다.
토큰이 남는 갈래는 <b>접두어 불일치</b>뿐이다(oBaseObj 가 Nothing 이라 Replace 를 안 한다).
소스를 읽어 고쳤다. 두 갈래를 섞으면 안 되는 이유는 하나는 ORA 구문오류가 되고
다른 하나는 조건이 사라진 SQL 이 조용히 도는 것이라서다.
SQL 에서는 같아지는 것들도 사람에게는 갈라서 말한다 —
Unknown(아직 안 옮긴 속성) / Empty(값이 빔) / NotAVariable(접두어 틀림).
셋은 고칠 곳이 전부 다르다.
## 짐작하지 않는 변수원(PatientQueryVariableSource)
DataRow 접근형이 이 기능의 대부분을 실어 준다 — PatInfDR.item("아무컬럼") 은
SELECT * 결과 사전을 그대로 조회하면 되므로 <b>내가 컬럼을 알 필요가 없다.</b>
짐작할 것이 없으니 조용히 틀릴 일도 없다.
스칼라도 대부분 그 다섯 행의 컬럼 하나라, 컬럼 이름만 적고 있으면 값·없으면 모른다고
답한다. 존재 여부는 DB 가 판정한다. UDF 산출값(Age·Sex)은 넣지 않았다.
## 값이 안 나올 때를 위한 창
종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄만 나온다.
그것으로는 쿼리가 틀렸는가·치환이 빈 값이 됐는가·이 환자에게 자료가 없는가를 못 가른다.
그래서 데이터소스 창이 <b>치환을 마친 SQL</b>을 그대로 보여 준다.
값이 안 나왔을 때 봐야 하는 것은 결과가 아니라 무엇을 물었는가다.
레거시에는 이걸 볼 수단이 없었다 — 런타임이 빈 catch 로 삼켜 "빈칸"만 남았다.
진단 화면 표본도 <b>실패 상태</b>로 찍는다. 성공 화면만 회귀 대상으로 두면
정작 사람이 오래 들여다보는 화면이 검사에서 빠진다.
## 안전
- 실행은 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
f50a7cff4b
commit
9bff3dd5f6
@@ -25,3 +25,17 @@ public interface ITagValueResolver
|
|||||||
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
|
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
|
||||||
TagValue Resolve(string tag);
|
TagValue Resolve(string tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// <c>DataTableField</c> 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
|
||||||
|
///
|
||||||
|
/// <b>태그와 다른 관이다.</b> 태그(<c>DataInterfaceTag</c>)는 함수 하나가 값을 만들지만,
|
||||||
|
/// 이쪽은 서식이 들고 있는 SQL 을 <b>실행</b>해서 나온 표에서 행·컬럼을 지목한다.
|
||||||
|
/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
|
||||||
|
/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
|
||||||
|
/// </summary>
|
||||||
|
public interface IDataFieldResolver
|
||||||
|
{
|
||||||
|
/// <summary>이 배선의 값 — 못 만들면 사유가 담긴다</summary>
|
||||||
|
TagValue Resolve(DataTableFieldSpec spec);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,183 @@
|
|||||||
|
using System.Text;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 치환 변수의 값을 내주는 쪽 — 미리보기는 환자 문맥이, 시험 실행은 표본이 맡는다.
|
||||||
|
/// </summary>
|
||||||
|
public interface IQueryVariableSource
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// 스칼라 속성 값. <b>null 은 "이 이름을 모른다"</b>, 빈 문자열은 "안다, 값이 비었다"다.
|
||||||
|
/// 둘은 SQL 에서 같게 처리되지만(레거시가 그렇다) 사람에게는 다르게 말해야 한다.
|
||||||
|
/// </summary>
|
||||||
|
string? Scalar(string className, string property);
|
||||||
|
|
||||||
|
/// <summary>DataRow 속성. null 은 그 행을 모른다는 뜻</summary>
|
||||||
|
IReadOnlyDictionary<string, string>? Row(string className, string property);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>치환 결과 한 건 — 무엇이 무엇으로 바뀌었는가</summary>
|
||||||
|
/// <param name="Token">원문 토큰(<c><<…>></c> 포함)</param>
|
||||||
|
/// <param name="Value">박힌 값</param>
|
||||||
|
/// <param name="Kind">어떻게 판정됐는가</param>
|
||||||
|
public readonly record struct QuerySubstitutionHit(string Token, string Value, QuerySubstitutionKind Kind);
|
||||||
|
|
||||||
|
/// <summary>치환 판정</summary>
|
||||||
|
public enum QuerySubstitutionKind
|
||||||
|
{
|
||||||
|
/// <summary>값이 있었다</summary>
|
||||||
|
Value,
|
||||||
|
|
||||||
|
/// <summary>이름은 아는데 값이 비었다 — 레거시도 빈 문자열로 박는다</summary>
|
||||||
|
Empty,
|
||||||
|
|
||||||
|
/// <summary>이름을 모른다(아직 안 옮긴 속성) — 레거시는 빈 문자열, 우리는 그 사실을 말한다</summary>
|
||||||
|
Unknown,
|
||||||
|
|
||||||
|
/// <summary>접두어가 세 클래스 중 어느 것도 아니다 — 레거시는 토큰을 <b>그대로 남긴다</b>(ORA 구문오류)</summary>
|
||||||
|
NotAVariable,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>치환을 마친 SQL 과 그 내역</summary>
|
||||||
|
public sealed record QuerySubstitutionResult(
|
||||||
|
string Sql, IReadOnlyList<QuerySubstitutionHit> Hits)
|
||||||
|
{
|
||||||
|
/// <summary>SQL 에 남은 토큰이 있는가 — 있으면 실행하면 ORA 구문오류다</summary>
|
||||||
|
public bool HasLeftover => Hits.Any(h => h.Kind == QuerySubstitutionKind.NotAVariable);
|
||||||
|
|
||||||
|
/// <summary>아직 옮기지 않은 속성들 — 화면이 "왜 비었나"를 설명할 때 쓴다</summary>
|
||||||
|
public IReadOnlyList<string> UnknownTokens
|
||||||
|
=> Hits.Where(h => h.Kind == QuerySubstitutionKind.Unknown).Select(h => h.Token).Distinct().ToList();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 따옴표가 섞여 들어간 값들 — <b>레거시는 값을 원문 그대로 박는다</b>.
|
||||||
|
/// 환자 이름에 <c>'</c> 가 있으면 SQL 이 깨지거나 문장이 바뀐다.
|
||||||
|
/// 막지는 않는다(레거시와 같은 SQL 을 만들어야 미리보기가 거짓말을 하지 않는다) —
|
||||||
|
/// 대신 <b>보이게</b> 한다. 실행 쪽은 SELECT/WITH 만 허용하는 문이 따로 있다.
|
||||||
|
/// </summary>
|
||||||
|
public IReadOnlyList<string> QuotedValues
|
||||||
|
=> Hits.Where(h => h.Value.Contains('\'') || h.Value.Contains(';'))
|
||||||
|
.Select(h => h.Token).Distinct().ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MDataTable 쿼리의 <c><<…>></c> 치환 — 레거시 <c>clsMDataTable.ConvertQuery</c> 를 옮긴 것
|
||||||
|
/// (<c>[003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb:24-71, :113</c>).
|
||||||
|
///
|
||||||
|
/// <b>규약을 그대로 지킨다.</b> 여기서 한 칸이라도 다르게 만들면 미리보기가 운영과 다른 SQL 을 돌린다 —
|
||||||
|
/// "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다.
|
||||||
|
/// <list type="number">
|
||||||
|
/// <item>토큰이 세 클래스 <b>전체 이름</b> 중 하나로 시작하는지 본다(StartsWith).
|
||||||
|
/// 축약형은 치환되지 않고 토큰이 SQL 에 남는다.</item>
|
||||||
|
/// <item>나머지를 <c>^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$</c> 로 해석한다 —
|
||||||
|
/// 스칼라와 DataRow 접근 2형태.</item>
|
||||||
|
/// <item><c>item</c> 은 <b>소문자만</b>이다(정규식이 대소문자를 구분한다).</item>
|
||||||
|
/// <item>값은 <b>원문 그대로</b> 박는다 — 따옴표를 붙여 주지 않는다.</item>
|
||||||
|
/// <item>값을 못 만들면 <b>빈 문자열</b>로 박는다(:67-71, :138). 조용히 사라진다.</item>
|
||||||
|
/// </list>
|
||||||
|
///
|
||||||
|
/// 순수 함수로 둔다. 갈래가 많고(스칼라/DataRow 2형태/미지원/접두어 불일치) 실DB 로는
|
||||||
|
/// 한 갈래씩 확인할 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
public static class QuerySubstitution
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>런타임 호스트의 속성 선언형 — <c>ucLoadSheetBase.vb:7619-7631</c></summary>
|
||||||
|
public const string PatientClass = "M.CMM.HISOperatingInfo.bzPatientInfo";
|
||||||
|
public const string WorkClass = "M.CMM.HISOperatingInfo.bzWorkInfo";
|
||||||
|
public const string SheetClass = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo";
|
||||||
|
|
||||||
|
/// <summary>긴 것부터 본다 — 접두어가 서로 겹치면 짧은 쪽이 먼저 맞아 나머지 해석이 깨진다</summary>
|
||||||
|
private static readonly string[] Classes = { PatientClass, WorkClass, SheetClass };
|
||||||
|
|
||||||
|
/// <summary>레거시와 같은 정규식. <c>item</c> 소문자, 경로 한 단계만</summary>
|
||||||
|
private static readonly Regex Member = new(
|
||||||
|
@"^([a-zA-Z_]\w*)(?:\.item\(""([^""]+)""\)|\(""([^""]+)""\))?$",
|
||||||
|
RegexOptions.Compiled | RegexOptions.CultureInvariant);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다</summary>
|
||||||
|
public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source)
|
||||||
|
{
|
||||||
|
var text = sql ?? string.Empty;
|
||||||
|
var hits = new List<QuerySubstitutionHit>();
|
||||||
|
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 구문오류가 된다.
|
||||||
|
// 조용히 지우면 <b>조건이 사라진 SQL</b>이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다.
|
||||||
|
output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value);
|
||||||
|
at = close + 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
return new QuerySubstitutionResult(output.ToString(), hits);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>토큰 안쪽을 값으로</summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -2033,6 +2033,46 @@ public static class DbSmoke
|
|||||||
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
|
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
|
||||||
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
|
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
|
||||||
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
|
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
|
||||||
|
|
||||||
|
// ── MDataTable 치환 + 실행 ──
|
||||||
|
// 태그와 다른 관이다. 여기까지 돌지 않으면 "미리보기는 되는데 실제 서식은
|
||||||
|
// 반쯤 빈" 상태를 확인할 수 없다. 실DB 로만 확인 가능한 것은
|
||||||
|
// <b>치환한 SQL 이 실제로 실행되는가</b>이므로 그것만 본다.
|
||||||
|
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 = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
|
||||||
|
+ " AND TRIM(ComChtNum) = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatChtNum\")>>'");
|
||||||
|
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}개");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -152,6 +152,9 @@ public static class DialogShots
|
|||||||
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
|
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
|
||||||
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
|
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
|
||||||
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
|
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
|
||||||
|
// 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
|
||||||
|
// 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
|
||||||
|
("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
|
||||||
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
|
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
|
||||||
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
|
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
|
||||||
("09-inspector-label", () => HostInspector(designer, "Label1")),
|
("09-inspector-label", () => HostInspector(designer, "Label1")),
|
||||||
@@ -424,6 +427,33 @@ public static class DialogShots
|
|||||||
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
|
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 데이터소스 표본 — 환자 없이 만든다. 그러면 치환이 전부 빈 값이 되어 SQL 이 깨지고,
|
||||||
|
/// 진단이 <b>실패 화면</b>을 찍는다. 그게 사람이 실제로 오래 보게 되는 화면이다.
|
||||||
|
/// DB 접속 여부와 무관하게 같은 그림이 나온다(치환 단계에서 이미 실패한다).
|
||||||
|
/// </summary>
|
||||||
|
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 = <<bzPatientInfo.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<DesignVersionInfo> SampleVersions() => new()
|
private static List<DesignVersionInfo> SampleVersions() => new()
|
||||||
{
|
{
|
||||||
new DesignVersionInfo(52443, "202608121030", "011825", false),
|
new DesignVersionInfo(52443, "202608121030", "011825", false),
|
||||||
|
|||||||
@@ -60,6 +60,25 @@ public static class EditSmoke
|
|||||||
SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag));
|
SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 검사용 배선 해석기 — 표 한 장을 손으로 쥐고 네 갈래(정상·컬럼오타·없는소스·형식깨짐)를 만든다.
|
||||||
|
/// 실행기(<see cref="Services.MDataTableRunner"/>)를 쓰면 DB 접속 여부에 판정이 묶인다.
|
||||||
|
/// </summary>
|
||||||
|
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)
|
public static int Run(string reportPath)
|
||||||
{
|
{
|
||||||
var lines = new List<string>();
|
var lines = new List<string>();
|
||||||
@@ -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);
|
||||||
|
|
||||||
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
|
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
|
||||||
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
|
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
|
||||||
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
|
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
using SheetMe.Core.Models;
|
||||||
|
using SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>한 MDataTable 의 실행 결과 — 또는 왜 실행하지 못했는지</summary>
|
||||||
|
/// <param name="Name">데이터소스 컨트롤 이름(배선이 이 이름으로 찾는다)</param>
|
||||||
|
/// <param name="Sql">치환을 마친 SQL — 사람이 눈으로 확인해야 하는 그 문장</param>
|
||||||
|
/// <param name="Columns">결과 컬럼</param>
|
||||||
|
/// <param name="Rows">결과 행</param>
|
||||||
|
/// <param name="Error">실패 사유. null 이면 성공</param>
|
||||||
|
/// <param name="Unknown">치환하지 못한 변수들 — 값이 빈 이유가 여기 있을 수 있다</param>
|
||||||
|
public sealed record MDataTableResult(
|
||||||
|
string Name, string Sql,
|
||||||
|
IReadOnlyList<string> Columns, IReadOnlyList<string[]> Rows,
|
||||||
|
string? Error, IReadOnlyList<string> Unknown)
|
||||||
|
{
|
||||||
|
public bool Ok => Error is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 미리보기에서 MDataTable 을 <b>실제로 돌린다</b> — 레거시 <c>ucLoadSheetBase</c> 가
|
||||||
|
/// 서식을 로드할 때 하던 일이다.
|
||||||
|
///
|
||||||
|
/// <b>왜 필요한가.</b> 서식의 값 상당수는 태그가 아니라 이 경로로 온다. 태그만 채워 보면
|
||||||
|
/// "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
|
||||||
|
///
|
||||||
|
/// <b>실행은 읽기만.</b> <see cref="OracleQueryWorkbench.Trial"/> 을 그대로 쓴다 —
|
||||||
|
/// SELECT/WITH 만 통과시키는 문이 이미 거기 있고, 이 접속은 운영에 붙을 수 있다.
|
||||||
|
/// 문을 두 군데 두면 한쪽이 느슨해진다.
|
||||||
|
///
|
||||||
|
/// <b>치환 값이 SQL 에 원문 그대로 박힌다</b>(레거시가 그렇다 — 그래야 같은 SQL 이 된다).
|
||||||
|
/// 따옴표가 섞인 값은 <see cref="QuerySubstitutionResult.QuotedValues"/> 로 드러내고
|
||||||
|
/// 실행은 SELECT/WITH 문이 막는다.
|
||||||
|
///
|
||||||
|
/// <b>한 번만 돌린다.</b> 컨트롤마다 돌리면 배선 40개짜리 서식에 왕복 40회가 된다 —
|
||||||
|
/// 데이터소스 단위로 캐시한다. 환자를 바꾸면 러너를 새로 만든다(같은 러너를 재사용하면
|
||||||
|
/// 옛 환자의 표가 남는다).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class MDataTableRunner : IDataFieldResolver
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private readonly IQueryVariableSource variables;
|
||||||
|
private readonly OracleQueryWorkbench? workbench;
|
||||||
|
private readonly Dictionary<string, MDataTableResult> cache = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
/// <summary>이름 → 그 데이터소스의 쿼리 원문</summary>
|
||||||
|
private readonly Dictionary<string, string> queries = new(StringComparer.OrdinalIgnoreCase);
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Constructors
|
||||||
|
/// <param name="document">데이터소스와 그 쿼리를 여기서 모은다</param>
|
||||||
|
/// <param name="variables">치환 값을 내주는 쪽 — 환자를 안 골랐으면 null</param>
|
||||||
|
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
|
||||||
|
/// <summary>이름이 붙은 데이터소스들 — 화면이 결과를 목록으로 보여 줄 때 쓴다</summary>
|
||||||
|
public IReadOnlyList<string> Names => queries.Keys.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||||
|
|
||||||
|
/// <summary>환자 문맥이 붙어 있는가 — 없으면 치환이 전부 빈 값이 되어 결과가 무의미하다</summary>
|
||||||
|
public bool HasContext => !ReferenceEquals(variables, EmptySource.Instance);
|
||||||
|
|
||||||
|
/// <summary>이 데이터소스를 돌린 결과 — 처음 물을 때 한 번만 실행한다</summary>
|
||||||
|
public MDataTableResult Run(string name)
|
||||||
|
{
|
||||||
|
if (cache.TryGetValue(name, out var cached))
|
||||||
|
{
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
var result = Execute(name);
|
||||||
|
cache[name] = result;
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>모두 돌린다 — 미리보기가 결과를 한꺼번에 보여 줄 때</summary>
|
||||||
|
public IReadOnlyList<MDataTableResult> 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 이 아니다).
|
||||||
|
// 아직 옮기지 않았다 — 무시하고 행 번호만 쓰면 <b>다른 행의 값</b>이 조용히 찍힌다.
|
||||||
|
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<string>(),
|
||||||
|
Array.Empty<string[]>(), "쿼리가 비어 있습니다", Array.Empty<string>());
|
||||||
|
}
|
||||||
|
var converted = QuerySubstitution.Apply(sqlRaw, variables);
|
||||||
|
if (converted.HasLeftover)
|
||||||
|
{
|
||||||
|
// 접두어가 클래스 전체 이름이 아니면 토큰이 SQL 에 남는다 — 실행하면 ORA 구문오류다.
|
||||||
|
// 미리 말해 준다. 오라클 오류 문구보다 이쪽이 고칠 곳을 알려 준다.
|
||||||
|
return new MDataTableResult(name, converted.Sql, Array.Empty<string>(), Array.Empty<string[]>(),
|
||||||
|
"치환되지 않은 변수가 남아 있습니다(접두어가 클래스 전체 이름이어야 합니다): "
|
||||||
|
+ 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<string>(),
|
||||||
|
Array.Empty<string[]>(), "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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>데이터소스를 재귀로 모은다 — 패널·그룹박스 안에 있는 것도 배선 대상이다</summary>
|
||||||
|
private void Collect(IEnumerable<ControlElement> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>환자를 안 골랐을 때 — 모든 변수를 "모른다"로 답한다</summary>
|
||||||
|
private sealed class EmptySource : IQueryVariableSource
|
||||||
|
{
|
||||||
|
public static readonly EmptySource Instance = new();
|
||||||
|
|
||||||
|
public string? Scalar(string className, string property) => null;
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
using SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 환자 문맥을 MDataTable 쿼리의 치환 변수로 내준다 — 레거시가 <c>bzPatientInfo</c> 인스턴스를
|
||||||
|
/// 넘기던 자리다.
|
||||||
|
///
|
||||||
|
/// <b>DataRow 접근형이 이 기능의 대부분을 실어 준다.</b>
|
||||||
|
/// <c>PatInfDR.item("아무컬럼")</c> 은 <see cref="PatientContext"/> 의 다섯 사전을 그대로 조회하면
|
||||||
|
/// 되고, 그 사전은 <c>SELECT *</c> 결과라 컬럼을 내가 미리 알 필요가 없다 —
|
||||||
|
/// <b>짐작할 것이 없으니 조용히 틀릴 일도 없다.</b>
|
||||||
|
///
|
||||||
|
/// 스칼라 속성도 대부분 그 다섯 행의 컬럼 하나다. 그래서 표에 컬럼 이름만 적고
|
||||||
|
/// <b>있으면 값, 없으면 모른다</b>고 답한다. 컬럼이 실제로 있는지는 DB 가 판정한다.
|
||||||
|
///
|
||||||
|
/// 산출값(Age·Sex 등 UDF 를 거치는 것)은 넣지 않았다 — 계산식을 짐작하면 값이 조용히 틀린다.
|
||||||
|
/// 넣지 않은 것은 <see cref="QuerySubstitutionKind.Unknown"/> 으로 드러난다.
|
||||||
|
/// </summary>
|
||||||
|
public sealed class PatientQueryVariableSource : IQueryVariableSource
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>스칼라 속성 → (문맥 행, 컬럼). 컬럼 존재 여부는 DB 결과가 판정한다</summary>
|
||||||
|
private static readonly Dictionary<string, (string Row, string Column)> 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<string, string>? 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<string, string> RowOf(string name) => name switch
|
||||||
|
{
|
||||||
|
"PatInf" => context.PatInf,
|
||||||
|
"ComInf" => context.ComInf,
|
||||||
|
"CodInf" => context.CodInf,
|
||||||
|
"CoiInf" => context.CoiInf,
|
||||||
|
_ => context.CowInf,
|
||||||
|
};
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -59,7 +59,8 @@ public static class PrintService
|
|||||||
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
|
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = 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
|
var canvas = new Canvas
|
||||||
{
|
{
|
||||||
@@ -91,7 +92,7 @@ public static class PrintService
|
|||||||
}
|
}
|
||||||
var presenter = new ContentPresenter
|
var presenter = new ContentPresenter
|
||||||
{
|
{
|
||||||
Content = PrintProjection(control, printFilter, tags),
|
Content = PrintProjection(control, printFilter, tags, fields),
|
||||||
Width = Math.Max(1, control.Width),
|
Width = Math.Max(1, control.Width),
|
||||||
Height = Math.Max(1, control.Height),
|
Height = Math.Max(1, control.Height),
|
||||||
};
|
};
|
||||||
@@ -121,11 +122,12 @@ public static class PrintService
|
|||||||
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
|
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private static object PrintProjection(ControlViewModel control, bool printFilter,
|
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)
|
if (control is not ContainerViewModel container)
|
||||||
{
|
{
|
||||||
return WithTagValue(control, tags);
|
return WithTagValue(control, tags, fields);
|
||||||
}
|
}
|
||||||
ContainerViewModel? copy = container switch
|
ContainerViewModel? copy = container switch
|
||||||
{
|
{
|
||||||
@@ -145,7 +147,7 @@ public static class PrintService
|
|||||||
{
|
{
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (PrintProjection(child, printFilter, tags) is ControlViewModel projected)
|
if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected)
|
||||||
{
|
{
|
||||||
copy.Children.Add(projected);
|
copy.Children.Add(projected);
|
||||||
}
|
}
|
||||||
@@ -169,24 +171,51 @@ public static class PrintService
|
|||||||
/// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다.
|
/// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다.
|
||||||
/// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다).
|
/// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
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)
|
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
|
||||||
|
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
|
||||||
|
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
|
||||||
|
var (label, value) = Bound(control, tags, fields);
|
||||||
|
if (label is null)
|
||||||
{
|
{
|
||||||
return control;
|
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
|
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();
|
var model = control.Model.Clone();
|
||||||
model.Props.SetText("Text", text);
|
model.Props.SetText("Text", text);
|
||||||
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
|
||||||
|
///
|
||||||
|
/// <c>DataTableField</c> 는 형식을 어기면 레거시 런타임이 <b>빈 catch 로 삼켜</b>
|
||||||
|
/// 아무 표시 없이 빈칸이 된다(ucLoadSheetBase.DataTableBinding_). 여기서는 파싱 실패도 말한다 —
|
||||||
|
/// 디자이너에서 정상으로 보이고 임상 화면에서만 빈칸이 되는 것이 이 배선의 고질적 고장이다.
|
||||||
|
/// </summary>
|
||||||
|
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
|
#endregion
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<Window x:Class="SheetMe.Designer.Views.DataSourceResultDialogView"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="데이터소스 조회 결과" Width="980" Height="640"
|
||||||
|
MinWidth="760" MinHeight="440"
|
||||||
|
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||||
|
Style="{StaticResource ThemedWindow}">
|
||||||
|
<DockPanel Margin="12">
|
||||||
|
|
||||||
|
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
|
||||||
|
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||||
|
<Button Content="SQL 복사" Padding="14,4" Click="OnCopySql"
|
||||||
|
ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/>
|
||||||
|
<Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="220"/>
|
||||||
|
<ColumnDefinition Width="6"/>
|
||||||
|
<ColumnDefinition Width="*"/>
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
|
||||||
|
<DockPanel Grid.Column="0">
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<ListBox x:Name="SourceList" SelectionChanged="OnSourceChanged"
|
||||||
|
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||||
|
AutomationProperties.Name="데이터소스 목록">
|
||||||
|
<ListBox.ItemTemplate>
|
||||||
|
<DataTemplate>
|
||||||
|
<StackPanel Orientation="Horizontal">
|
||||||
|
<!-- 실패한 것을 목록에서 바로 알아야 한다 — 하나씩 눌러 보게 만들면 안 된다 -->
|
||||||
|
<TextBlock Text="{Binding Mark}" Width="16" FontWeight="Bold">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding Ok}" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
|
<TextBlock Text="{Binding Name}"/>
|
||||||
|
<TextBlock Text="{Binding Suffix}" Margin="6,0,0,0"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ListBox.ItemTemplate>
|
||||||
|
</ListBox>
|
||||||
|
</DockPanel>
|
||||||
|
|
||||||
|
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||||
|
|
||||||
|
<DockPanel Grid.Column="2">
|
||||||
|
<!--
|
||||||
|
치환을 마친 SQL 이 이 창의 핵심이다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||||
|
결과가 아니라 <b>무엇을 물었는가</b>다 — 치환이 빈 값이 되면 조건이 사라진
|
||||||
|
SQL 이 되고, 그건 SQL 을 봐야만 알 수 있다.
|
||||||
|
-->
|
||||||
|
<TextBlock DockPanel.Dock="Top" Text="치환을 마친 SQL" Margin="0,0,0,4"
|
||||||
|
Foreground="{DynamicResource B.Muted}"/>
|
||||||
|
<TextBox x:Name="SqlBox" DockPanel.Dock="Top" Height="150" Margin="0,0,0,8"
|
||||||
|
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"
|
||||||
|
VerticalScrollBarVisibility="Auto" FontFamily="Consolas"
|
||||||
|
AutomationProperties.Name="치환을 마친 SQL"/>
|
||||||
|
|
||||||
|
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,6" TextWrapping="Wrap"/>
|
||||||
|
|
||||||
|
<DataGrid x:Name="ResultGrid" IsReadOnly="True" AutoGenerateColumns="True"
|
||||||
|
HeadersVisibility="Column" GridLinesVisibility="All"
|
||||||
|
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||||
|
AutomationProperties.Name="조회 결과"/>
|
||||||
|
</DockPanel>
|
||||||
|
</Grid>
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
using System.Data;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Views;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MDataTable 조회 결과를 펼쳐 본다.
|
||||||
|
///
|
||||||
|
/// <b>왜 이 창이 필요한가.</b> 종이에는 <c>[MDataTable1.ALGYON — 조회 결과가 0행입니다]</c>처럼
|
||||||
|
/// 한 줄만 나온다. 그것으로는 세 가지를 가릴 수 없다 —
|
||||||
|
/// 쿼리 자체가 틀렸는가, 치환이 빈 값이 되어 조건이 사라졌는가, 이 환자에게 정말 자료가 없는가.
|
||||||
|
/// 셋은 고칠 곳이 전부 다르다.
|
||||||
|
///
|
||||||
|
/// 그래서 <b>치환을 마친 SQL</b>을 그대로 보여 준다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||||
|
/// 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 —
|
||||||
|
/// 런타임이 빈 catch 로 삼켜서 현장에서 "빈칸"만 남았다.
|
||||||
|
/// </summary>
|
||||||
|
public partial class DataSourceResultDialogView : Window
|
||||||
|
{
|
||||||
|
#region Nested Types
|
||||||
|
/// <summary>목록 한 줄 — 실패한 것을 눌러 보지 않고도 알아야 한다</summary>
|
||||||
|
private sealed record Row(string Name, bool Ok, string Suffix, MDataTableResult Source)
|
||||||
|
{
|
||||||
|
public string Mark => Ok ? "✓" : "✕";
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Constructors
|
||||||
|
/// <summary>진단(--dialog-shots)이 직접 만든다 — 앱 코드는 ShowFor 를 쓸 것</summary>
|
||||||
|
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<string>();
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 결과를 DataGrid 가 받을 표로. 컬럼명이 중복되거나 비면 <c>DataTable</c> 이 예외를 낸다 —
|
||||||
|
/// 운영 쿼리에는 별칭 없는 표현식(<c>COUNT(*)</c> 등)이 흔하므로 여기서 이름을 만들어 준다.
|
||||||
|
/// </summary>
|
||||||
|
private static DataTable? ToTable(MDataTableResult result)
|
||||||
|
{
|
||||||
|
if (result.Columns.Count == 0)
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
var table = new DataTable();
|
||||||
|
var used = new HashSet<string>(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
|
||||||
|
}
|
||||||
@@ -30,6 +30,13 @@
|
|||||||
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
|
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
|
||||||
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient"
|
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient"
|
||||||
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
|
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
|
||||||
|
<!--
|
||||||
|
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
|
||||||
|
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
|
||||||
|
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
|
||||||
|
-->
|
||||||
|
<Button x:Name="DataSourceButton" Content="데이터소스..." Padding="10,3" Click="OnShowDataSources"
|
||||||
|
IsEnabled="False" ToolTip="MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"/>
|
||||||
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
|
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
|
||||||
MaxWidth="360" TextTrimming="CharacterEllipsis"
|
MaxWidth="360" TextTrimming="CharacterEllipsis"
|
||||||
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
|
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ public partial class PreviewWindow : Window
|
|||||||
/// 화면이 실제와 다른 것을 보여 준다.
|
/// 화면이 실제와 다른 것을 보여 준다.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
private SheetMe.Core.Catalog.ITagValueResolver tags;
|
private SheetMe.Core.Catalog.ITagValueResolver tags;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MDataTable 실행기 — <c>DataTableField</c> 배선의 값을 낸다.
|
||||||
|
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
|
||||||
|
/// </summary>
|
||||||
|
private MDataTableRunner? fields;
|
||||||
|
|
||||||
|
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
|
||||||
|
private SheetMe.Data.Stores.PatientContext? patient;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructors
|
#region Constructors
|
||||||
@@ -61,7 +70,9 @@ public partial class PreviewWindow : Window
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
Subscribe();
|
Subscribe();
|
||||||
BuildPages();
|
// 환자 없이도 러너를 만든다 — 데이터소스가 몇 개 있는지는 지금 알 수 있어야 하고,
|
||||||
|
// 그래야 '데이터소스' 버튼이 처음부터 맞는 상태로 뜬다.
|
||||||
|
UsePatient(null, string.Empty);
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
@@ -97,7 +108,9 @@ public partial class PreviewWindow : Window
|
|||||||
Unsubscribe();
|
Unsubscribe();
|
||||||
designer = next;
|
designer = next;
|
||||||
Subscribe();
|
Subscribe();
|
||||||
BuildPages();
|
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
|
||||||
|
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
|
||||||
|
UsePatient(patient, PatientText.Text);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
|
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
|
||||||
@@ -151,7 +164,7 @@ public partial class PreviewWindow : Window
|
|||||||
BorderBrush = System.Windows.Media.Brushes.LightGray,
|
BorderBrush = System.Windows.Media.Brushes.LightGray,
|
||||||
BorderThickness = new Thickness(1),
|
BorderThickness = new Thickness(1),
|
||||||
Margin = new Thickness(0, 0, 0, 20),
|
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,
|
Width = page.WidthDip,
|
||||||
Height = page.HeightDip,
|
Height = page.HeightDip,
|
||||||
};
|
};
|
||||||
@@ -192,12 +205,34 @@ public partial class PreviewWindow : Window
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
||||||
{
|
{
|
||||||
|
patient = picked;
|
||||||
tags = picked is null ? session : new PatientTagResolver(picked, session);
|
tags = picked is null ? session : new PatientTagResolver(picked, session);
|
||||||
|
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
||||||
|
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
||||||
|
fields = new MDataTableRunner(designer.Document,
|
||||||
|
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
|
||||||
PatientText.Text = picked is null ? "환자 없음" : label;
|
PatientText.Text = picked is null ? "환자 없음" : label;
|
||||||
PatientClearButton.IsEnabled = picked is not null;
|
PatientClearButton.IsEnabled = picked is not null;
|
||||||
|
DataSourceButton.IsEnabled = fields.Names.Count > 0;
|
||||||
BuildPages();
|
BuildPages();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 <b>왜</b>인지는 여기서만 알 수 있다.
|
||||||
|
///
|
||||||
|
/// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
|
||||||
|
/// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
|
||||||
|
/// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
|
||||||
|
/// </summary>
|
||||||
|
private void OnShowDataSources(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (fields is null)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
DataSourceResultDialogView.ShowFor(fields, this);
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
|
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
|
||||||
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
|
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,161 @@
|
|||||||
|
using SheetMe.Core.Catalog;
|
||||||
|
|
||||||
|
namespace SheetMe.Core.Tests;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// MDataTable 쿼리 치환 고정 — 레거시 <c>clsMDataTable.ConvertQuery</c> 와 같은 SQL 이 나와야 한다.
|
||||||
|
///
|
||||||
|
/// 한 칸이라도 다르면 미리보기가 운영과 <b>다른 SQL</b>을 돌린다. 그러면 "미리보기에서는
|
||||||
|
/// 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다 —
|
||||||
|
/// 사람이 확인했다고 믿고 넘어간다.
|
||||||
|
/// </summary>
|
||||||
|
[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<string, string>? Row(string className, string property)
|
||||||
|
=> (className, property) switch
|
||||||
|
{
|
||||||
|
(QuerySubstitution.PatientClass, "PatInfDR") =>
|
||||||
|
new Dictionary<string, string>(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 = <<M.CMM.HISOperatingInfo.bzPatientInfo.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 <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNam\")>>");
|
||||||
|
Assert.AreEqual("X 가상환자", withItem.Sql);
|
||||||
|
|
||||||
|
// 괄호만 쓰는 형태도 정규식이 받는다
|
||||||
|
var bare = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR(\"PatNam\")>>");
|
||||||
|
Assert.AreEqual("X 가상환자", bare.Sql);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void item_은_대문자면_값이_되지_않고_지워진다()
|
||||||
|
{
|
||||||
|
// 정규식이 대소문자를 구분한다(clsMDataTable.vb:112). 이걸 관대하게 만들면
|
||||||
|
// 레거시에서 안 되는 서식이 미리보기에서만 되고, 현장에서 빈칸으로 나온다.
|
||||||
|
//
|
||||||
|
// 지워지는 것이 맞다 — GetPropertyInfo 가 정규식 실패에 ""를 돌려주고(:118)
|
||||||
|
// 호출부가 그것을 빈 문자열로 치환한다(:66-70). 접두어 불일치와는 <b>다른</b> 갈래다:
|
||||||
|
// 그쪽은 oBaseObj 가 Nothing 이라 Replace 를 아예 하지 않아 토큰이 남는다.
|
||||||
|
var r = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.Item(\"PatNam\")>>");
|
||||||
|
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 = <<bzPatientInfo.ComNum>>");
|
||||||
|
StringAssert.Contains(r.Sql, "<<bzPatientInfo.ComNum>>");
|
||||||
|
Assert.IsTrue(r.HasLeftover);
|
||||||
|
Assert.AreEqual(QuerySubstitutionKind.NotAVariable, r.Hits[0].Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 모르는_속성과_빈_값을_구분한다()
|
||||||
|
{
|
||||||
|
// SQL 에서는 둘 다 빈 문자열이다(레거시와 같게) — 사람에게는 달라야 한다.
|
||||||
|
var unknown = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.Age>>B");
|
||||||
|
Assert.AreEqual("AB", unknown.Sql);
|
||||||
|
Assert.AreEqual(QuerySubstitutionKind.Unknown, unknown.Hits[0].Kind);
|
||||||
|
Assert.AreEqual(1, unknown.UnknownTokens.Count);
|
||||||
|
|
||||||
|
var empty = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.EmgKTSGrd>>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<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNamX\")>>B");
|
||||||
|
Assert.AreEqual("AB", r.Sql);
|
||||||
|
Assert.AreEqual(QuerySubstitutionKind.Empty, r.Hits[0].Kind);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 여러_토큰과_사이_글자를_보존한다()
|
||||||
|
{
|
||||||
|
var r = Run("WHERE A = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatTyp>>'"
|
||||||
|
+ " AND B = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
|
||||||
|
+ " AND C = '<<M.CMM.HISOperatingInfo.bzWorkInfo.WrkUid>>'");
|
||||||
|
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 = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
|
||||||
|
StringAssert.Contains(r.Sql, "<<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
|
||||||
|
Assert.AreEqual(0, r.Hits.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 토큰이_없으면_원문_그대로다()
|
||||||
|
{
|
||||||
|
const string sql = "SELECT * FROM P_PatInf WHERE ROWNUM <= 10";
|
||||||
|
Assert.AreEqual(sql, Run(sql).Sql);
|
||||||
|
Assert.AreEqual(0, Run(sql).Hits.Count);
|
||||||
|
}
|
||||||
|
|
||||||
|
[TestMethod]
|
||||||
|
public void 따옴표가_섞인_값을_드러낸다()
|
||||||
|
{
|
||||||
|
// 레거시는 값을 원문 그대로 박는다 — 이름에 따옴표가 있으면 SQL 이 깨지거나 문장이 바뀐다.
|
||||||
|
// 막지 않는다(같은 SQL 을 만들어야 미리보기가 거짓말을 안 한다). 대신 보이게 한다.
|
||||||
|
var source = new QuoteSource();
|
||||||
|
var r = QuerySubstitution.Apply(
|
||||||
|
"WHERE Nam = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'", source);
|
||||||
|
Assert.AreEqual(1, r.QuotedValues.Count, $"드러나지 않았다: {r.Sql}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed class QuoteSource : IQueryVariableSource
|
||||||
|
{
|
||||||
|
public string? Scalar(string className, string property) => "O'Brien";
|
||||||
|
|
||||||
|
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user