Files
SheetMe/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
T
MsystechandClaude Opus 5 0da3305a75 상병 11종을 옮긴다 — SQL 은 원문에서 기계 추출했다. 40종 → 51종
## Okd_상병쿼리(bzDataInterface.vb:14160-14623)의 이식

구조: 블록1(M_KcdMst 와 맞는 상병, INNER JOIN) UNION 블록2(마스터에 없는 코드 —
LEFT JOIN 후 KcdCod IS NULL). 주/부 필터(OkdDspSeq='1' / <>'1')가 <b>각 블록에,
ROW_NUMBER 접기 앞에</b> 붙는다. 그래서 전체(T)를 받아 클라이언트에서 가르면 안 된다 —
같은 코드가 주·부 양쪽에 있으면 RN 접기가 한쪽을 지워 결과가 갈린다.
갈래(T/M/S)별로 따로 조회하되, 태그가 물을 때 한 번만 읽고 캐시한다.

SQL 은 손으로 다시 치지 않고 <b>VB 원문에서 기계 추출했다</b>(Select Case 를
Case Else 경로로 시뮬레이션, AppendLine 페이로드만 수집) — 55줄이 공백까지 원문 그대로다.

## 병원 분기를 얼버무리지 않는다

원문은 쿼리 자체가 6갈래(KIMEYE·NYJB·SYBS·HIMCHAN_CW·SPHH·Else)로 갈리고,
이름 규칙이 JEGG(배제 접미), 코드 규칙이 BSGH(KcdSeqCod)에서 또 갈린다.
이 DB(SRCH)의 Case Else 만 옮겼고, 그 병원들에서는 <b>미이식이라고 말한다</b> —
Else 로 뭉개면 그 병원 서식에 다른 모양의 상병이 조용히 찍힌다.

NYJB 갈래에는 사용자 코드를 SQL 에 <b>문자열로 잇는</b> 주입 구멍도 있다(:14235) —
옮길 때 바인드로 바꿔야 한다는 것을 조사 문서가 이미 적어 뒀다.

## 태그 11종 (전부 본문에서 결합 규칙 확인)

  상병명_가로/세로 · 영어_가로/세로 · 코드_가로/세로   전체(T)
  부상병코드 · 부상병POA                               부(S)
  주상병명 · 주상병코드 · 주상병POA                    주(M) 첫 행

가로 = ", " 구분, 세로 = 줄바꿈. 한글명에만 확→(확진)·의→(의증) 접미.
주상병명은 접미 없이 KcdKorNam(:10126), 주상병코드는 KcdElcCod(:9744).

## 실DB 확인 (--db-patient ㉕~㉗ 신규)

O_OkdInf 에 행을 가진 내원을 찾아 걸었다(행 없는 내원으로 판정하는 함정을 이미 두 번 밟았다).
내원 6005266: 전체 1행 = 주 1 + 부 0, 한글명 11자. 주/부 필터 분해가 전체와 일치한다.

## 게이트

- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 51종)
- --db-patient ①~㉗ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:15:29 +09:00

288 lines
13 KiB
C#

using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Views;
/// <summary>
/// 미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.
///
/// <b>창은 하나만 뜬다.</b> 전에는 누를 때마다 새 창이 생겼다. 모덜리스라 여러 장이 겹쳐 쌓이고,
/// 각각이 <b>서로 다른 시점의 서식</b>을 들고 있었다. 어느 것이 지금 것인지 화면으로 구분할 수 없다.
/// 환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 바꿔도 옛 창이 옛 환자를 계속 띄운다.
///
/// <b>내용은 따라간다.</b> 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제하거나
/// 숨김·인쇄출력을 토글해도 창은 옛 그림을 계속 보여 줬다 —
/// 저장 전에 결과를 확인하려고 여는 창이 <b>구조적으로 거짓말</b>을 하고 있었다.
/// 페이지·컨트롤 추가/삭제는 즉시, 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다.
/// 즉시 갱신이 안 되는 변경이 남아 있으므로 <b>새로 고침 버튼</b>도 함께 둔다.
/// </summary>
public partial class PreviewWindow : Window
{
#region Member Fields
/// <summary>지금 떠 있는 창 — 두 번째를 만들지 않는다</summary>
private static PreviewWindow? open;
private DesignerViewModel designer;
private double zoom = 1.0;
/// <summary>
/// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
/// </summary>
private readonly SessionTagResolver session = new();
/// <summary>
/// 지금 쓰는 해석기. 환자를 고르면 <see cref="PatientTagResolver"/> 가 앞에 붙고,
/// 해제하면 다시 <see cref="session"/> 뿐이다 — 환자를 해제했는데 옛 값이 남으면
/// 화면이 실제와 다른 것을 보여 준다.
/// </summary>
private SheetMe.Core.Catalog.ITagValueResolver tags;
/// <summary>
/// MDataTable 실행기 — <c>DataTableField</c> 배선의 값을 낸다.
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
/// </summary>
private MDataTableRunner? fields;
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
private SheetMe.Data.Stores.PatientContext? patient;
#endregion
#region Constructors
/// <summary>
/// 진단(--dialog-shots)만 직접 부른다 — 앱 코드는 <see cref="ShowFor"/> 를 쓸 것.
/// 여기로 만들면 단일 인스턴스 등록을 건너뛰므로 두 장이 생긴다.
/// </summary>
internal PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
tags = session;
InitializeComponent();
Activated += (_, _) => BuildPages();
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) =>
{
Unsubscribe();
PatientSession.Changed -= OnPatientSessionChanged;
if (ReferenceEquals(open, this))
{
open = null;
}
};
Subscribe();
// 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
UsePatient(PatientSession.Current, PatientSession.Label);
}
#endregion
#region Methods
/// <summary>
/// 미리보기를 보여 준다 — 이미 떠 있으면 그 창을 <b>지금 문서로 바꿔</b> 앞으로 가져온다.
/// 새 창을 하나 더 만들면 어느 것이 지금 것인지 알 수 없다.
/// </summary>
public static void ShowFor(DesignerViewModel designer, Window? owner)
{
if (open is null)
{
open = new PreviewWindow(designer) { Owner = owner };
open.Show();
return;
}
open.Retarget(designer);
if (open.WindowState == WindowState.Minimized)
{
open.WindowState = WindowState.Normal;
}
open.Activate();
}
/// <summary>다른 문서로 갈아탄다 — 탭을 바꾸고 미리보기를 다시 누른 경우</summary>
private void Retarget(DesignerViewModel next)
{
if (ReferenceEquals(designer, next))
{
BuildPages();
return;
}
Unsubscribe();
designer = next;
Subscribe();
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
UsePatient(patient, PatientText.Text);
}
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
private void Subscribe()
{
designer.Pages.CollectionChanged += OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged += OnStructureChanged;
}
}
private void Unsubscribe()
{
designer.Pages.CollectionChanged -= OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
}
}
private void OnStructureChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
// 페이지가 늘거나 줄면 그 페이지의 컨트롤 구독도 다시 걸어야 한다
if (ReferenceEquals(sender, designer.Pages))
{
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
page.Controls.CollectionChanged += OnStructureChanged;
}
}
BuildPages();
}
private void BuildPages()
{
// XAML 파싱 중에도 불린다 — 토글의 IsChecked="True" 가 파스 시점에 Checked 를 발생시키는데
// 그때 PagesHost 는 아직 만들어지지 않았다(토글이 트리에서 앞에 있다).
// 이 가드가 없으면 InitializeComponent 안에서 NullReferenceException 이 난다.
if (PagesHost is null)
{
return;
}
PagesHost.Children.Clear();
foreach (var page in designer.Pages)
{
var frame = new Border
{
Background = page.PaperBrush,
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
Width = page.WidthDip,
Height = page.HeightDip,
};
PagesHost.Children.Add(frame);
}
Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}"
: "미리보기";
}
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다.
///
/// 권한 문·감사 기록·문맥 읽기는 모두 <see cref="PatientPickerDialogView.Pick"/> 안에 있다 —
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
/// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e)
{
var picked = PatientPickerDialogView.Pick(this, out var label);
if (picked is null)
{
return;
}
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다.
// 여기서 직접 UsePatient 도 부르면 두 번 그리고, 더 나쁘게는
// 세션과 창이 어긋나는 경로가 하나 더 생긴다.
PatientSession.Set(picked, label);
}
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
private void OnClearPatient(object sender, RoutedEventArgs e)
=> PatientSession.Set(null, string.Empty);
private void OnPatientSessionChanged(object? sender, EventArgs e)
=> UsePatient(PatientSession.Current, PatientSession.Label);
/// <summary>
/// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
///
/// <b>모달과 분리하는 이유</b>: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
/// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
/// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
/// </summary>
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null
? session
: new PatientTagResolver(picked, session,
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalNameOf(picked),
PatientIdentity.DiagnosisSourceFor(picked));
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null;
DataSourceButton.IsEnabled = fields.Names.Count > 0;
BuildPages();
}
/// <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>
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
private void OnPrint(object sender, RoutedEventArgs e)
{
try
{
// 이 창이 종이에 그리는 것과 <b>같은 해석기</b>로 인쇄한다.
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
: designer.Document.FormId, tags, fields);
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "인쇄 중 오류가 발생했습니다.", ex.Message, this);
}
}
private void OnZoomIn(object sender, RoutedEventArgs e) => ApplyZoom(zoom * 1.15);
private void OnZoomOut(object sender, RoutedEventArgs e) => ApplyZoom(zoom / 1.15);
private void ApplyZoom(double value)
{
zoom = Math.Clamp(value, 0.25, 3.0);
ZoomTransform.ScaleX = zoom;
ZoomTransform.ScaleY = zoom;
ZoomText.Text = $"{zoom * 100:0}%";
}
#endregion
}