## GetOprInfDT(bzDataInterface.vb:13203-13363)의 이식 상병과 달리 병원 분기가 없다(Select Case 0회). 바인드는 내원번호 하나 — 접수일자 조건은 원문에서 이미 주석 처리된 죽은 코드다. SQL 133줄을 원문에서 기계 추출했다(O_OprInf 축, 수술명·부위·상병·집도의·마취의 LISTAGG). 소비 태그 8종이 전부 <b>첫 행에서 컬럼 하나</b>를 집는다: OCM_OPNAME · OPRCODNAM · DX · 수술상병(OkdNam) · 수술부위(OprRegionName) 수술_OprPatETC · 수술집도의(OprDtrNam) · 수술마취의사(AneUidCod) 원문 Catch 는 ErrorMessageBox 를 띄운다 — 조회 함수의 대화상자 부작용은 옮기지 않고 던져서 해석기가 사유로 바꾼다. 수술 조회도 상병과 같은 지연 정책이다 (수술 태그가 없는 서식이 다수라 환자를 붙일 때 무조건 읽지 않는다). ## 검증의 한계를 정확히 적는다 --db-patient ㉘ 이 O_OprInf 에서 내원을 못 찾았고, 처음엔 "행이 없다"로 적었다. 가려 보니 <b>ORA-00942 — 이 시험 DB(SRCH_TEST)에 표 자체가 없다.</b> 즉 수술 태그는 여기서 실행 검증이 불가능하고 운영 DB 에서만 확인할 수 있다. SKIP 문구를 그 사실대로 고쳤다 — "확인했다"고 적을 뻔한 것을 진단이 막았다. 런타임에서는 해석기가 예외를 잡아 "수술 조회에 실패했습니다"로 말한다. ## 게이트 - dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 59종) - --db-patient ①~㉗ 통과, ㉘ SKIP(표 부재 명시) - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
289 lines
13 KiB
C#
289 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),
|
|
PatientIdentity.SurgerySourceFor(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
|
|
}
|