## 가장 흔한 입력이 조용히 0건이었다 차트번호는 <b>완전일치</b>다. 병원 P_PatInf 는 고정 폭이라 실제 값이 0으로 채워져 있는데 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다. 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데(PatientVisitStore.cs:8) 유일한 호출부가 그 계약을 안 지키고 있었다. <b>자릿수를 설정으로 정하지 않았다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다. 대신 0건일 때만 8·10자리로 한 번 더 찾는다(레거시가 쓰던 두 폭이다). 찾았으면 "자릿수를 채워 '0000012345' 로 찾았습니다"를 적고 그 번호를 칸에 되쓴다 — 다음부터 그 번호를 쓸 수 있게. 틀릴 여지가 없는 방식이다. ## 0건일 때 무엇으로 찾았는지 말하지 않았다 유형이 틀려서 0건인 경우가 가장 흔한데(습관대로 이름을 쳤는데 유형은 차트번호) 화면에 힌트가 하나도 없어 접속이나 권한을 의심하게 된다. "없습니다(차트번호 완전일치로 찾았습니다)"로 유형과 일치 방식을 함께 적는다. ## '기준 시점'이 무슨 뜻인지 알 수 없었다 입원 내원은 <b>구간</b>이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 하는데, 목록에 접수일시만 있어서 그것이 구간이라는 사실 자체가 안 보였다. 이미 조회돼 있으면서 화면에 없던 <b>퇴원일시</b> 열을 붙이고, 선택지를 '마지막(퇴원 시점)'·'처음(입원 시점)'으로 바꿔 무엇을 고르는지 글자로 말한다. 열을 하나 더하면서 상태 열이 화면 밖으로 밀리고 콤보 글자가 잘렸다 — 사진으로 확인하고 일곱 열이 다 들어오도록 폭을 다시 잡았다. ## 판정 - 단위 시험 388 · edit-smoke 390건 전건 통과 - --dialog-shots 69장 전건 통과(다크·라이트 눈으로 확인 — 열·콤보 잘림 없음) - --modal-check · --maxrect · --scale-budget 전건 통과 - --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 ## 남은 것 검색 유형 자동 판별(입력 모양으로 주민번호·휴대전화·성명·차트번호를 가려내는 것)은 안 넣었다 — 콤보를 사람 몰래 바꾸는 동작이라 "왜 유형이 저절로 바뀌지"를 새로 만든다. 0건 안내가 유형을 밝히게 된 것으로 같은 혼란의 대부분은 없어진다. 자릿수 보정 뒤 재조회는 DB 가 붙은 단말에서 실제 차트번호로 확인이 필요하다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
274 lines
12 KiB
C#
274 lines
12 KiB
C#
using System.Windows;
|
|
using System.Windows.Input;
|
|
using SheetMe.Core.Catalog;
|
|
using SheetMe.Data.Stores;
|
|
using SheetMe.Designer.DataBusiness;
|
|
using SheetMe.Designer.Services;
|
|
|
|
namespace SheetMe.Designer.Views;
|
|
|
|
/// <summary>
|
|
/// 미리보기에 쓸 <b>실제 환자·내원</b>을 고른다.
|
|
///
|
|
/// <b>왜 모달인가.</b> 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다.
|
|
/// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데,
|
|
/// 이 창은 실제 환자 정보를 꺼내는 창이라 <b>열려 있는 것 자체가 비용</b>이다.
|
|
///
|
|
/// <b>왜 두 목록을 한 화면에 두는가.</b> 고르는 대상은 환자가 아니라 <b>내원</b>이다.
|
|
/// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로(<see cref="PatientContextStore"/>)
|
|
/// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를
|
|
/// 다시 확인하러 왕복해야 한다.
|
|
///
|
|
/// <b>문맥을 여기서 읽는다.</b> 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고,
|
|
/// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다.
|
|
/// </summary>
|
|
public partial class PatientPickerDialogView : Window
|
|
{
|
|
#region Member Fields
|
|
private readonly PatientVisitStore? store;
|
|
private readonly PatientContextStore? contexts;
|
|
#endregion
|
|
|
|
#region Constructors
|
|
public PatientPickerDialogView()
|
|
{
|
|
InitializeComponent();
|
|
var connection = ConfigService.Current.ConnectionString;
|
|
if (connection.Length > 0)
|
|
{
|
|
store = new PatientVisitStore(connection);
|
|
contexts = new PatientContextStore(connection);
|
|
}
|
|
Loaded += (_, _) => TermBox.Focus();
|
|
}
|
|
#endregion
|
|
|
|
#region Methods
|
|
/// <summary>고른 내원의 환자 문맥 — 취소했으면 null</summary>
|
|
public PatientContext? Picked { get; private set; }
|
|
|
|
/// <summary>고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다</summary>
|
|
public string PickedLabel { get; private set; } = string.Empty;
|
|
|
|
/// <summary>
|
|
/// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다.
|
|
/// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다.
|
|
/// </summary>
|
|
public static PatientContext? Pick(Window? owner, out string label)
|
|
{
|
|
label = string.Empty;
|
|
if (!FormDesignDataBusiness.CanPreviewPatient())
|
|
{
|
|
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
|
"실제 환자로 미리보기를 할 수 없습니다.",
|
|
FormDesignDataBusiness.PreviewPatientDenyReason(), owner);
|
|
return null;
|
|
}
|
|
var dialog = new PatientPickerDialogView { Owner = owner };
|
|
if (dialog.ShowDialog() != true || dialog.Picked is not { } picked)
|
|
{
|
|
return null;
|
|
}
|
|
// 감사 기록은 <b>고른 직후</b> 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
|
|
// 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다).
|
|
AppLog.Audit(PatientPreviewPolicy.AuditLine(
|
|
UserSession.Current.UidCod, picked.ComNum.ToString("F0")));
|
|
label = dialog.PickedLabel;
|
|
return picked;
|
|
}
|
|
|
|
private void OnTermKeyDown(object sender, KeyEventArgs e)
|
|
{
|
|
if (e.Key == Key.Enter)
|
|
{
|
|
e.Handled = true;
|
|
OnSearch(sender, e);
|
|
}
|
|
}
|
|
|
|
/// <summary>검색 유형의 사람이 읽는 이름 — 0건 안내가 "무엇으로 찾았는지" 말할 때 쓴다</summary>
|
|
private static string KindLabel(PatientSearchKind kind) => kind switch
|
|
{
|
|
PatientSearchKind.Name => "성명 접두일치",
|
|
PatientSearchKind.ResidentNumber => "주민번호 접두일치",
|
|
PatientSearchKind.MobilePhone => "휴대전화 부분일치",
|
|
_ => "차트번호 완전일치",
|
|
};
|
|
|
|
private void OnSearch(object sender, RoutedEventArgs e)
|
|
{
|
|
if (store is null)
|
|
{
|
|
DialogService.Notify(DialogKind.Warning, "환자 검색",
|
|
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
|
|
return;
|
|
}
|
|
var kind = KindCombo.SelectedIndex switch
|
|
{
|
|
1 => PatientSearchKind.Name,
|
|
2 => PatientSearchKind.ResidentNumber,
|
|
3 => PatientSearchKind.MobilePhone,
|
|
_ => PatientSearchKind.ChartNumber,
|
|
};
|
|
|
|
VisitList.ItemsSource = null;
|
|
VisitCountText.Text = "내원";
|
|
try
|
|
{
|
|
// 동기로 둔다. 행 상한이 200이라 오래 걸리지 않고, 비동기로 만들면
|
|
// "검색 중에 또 검색"을 다뤄야 해서 이 창 크기에 맞지 않는 복잡도가 생긴다.
|
|
Mouse.OverrideCursor = Cursors.Wait;
|
|
SearchButton.IsEnabled = false;
|
|
var found = store.Search(kind, TermBox.Text);
|
|
var padded = string.Empty;
|
|
|
|
// 차트번호는 <b>완전일치</b>다. 병원마다 자릿수가 정해져 있어 실제 값은 0으로 채워져 있는데
|
|
// (P_PatInf 가 고정 폭이다) 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
|
|
// 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데 이 호출부가 그걸 안 지켰다.
|
|
//
|
|
// <b>자릿수를 설정으로 정하지 않는다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
|
|
// 대신 0건일 때만 한 번 더 찾는다 — 8·10자리는 레거시가 쓰던 두 폭이다.
|
|
// 찾았으면 무엇으로 찾았는지 칸에 되써서 사용자가 그 번호를 기억하게 한다.
|
|
if (found.Count == 0 && kind == PatientSearchKind.ChartNumber
|
|
&& TermBox.Text.Trim() is { Length: > 0 } typed && typed.All(char.IsDigit))
|
|
{
|
|
foreach (var width in new[] { 8, 10 })
|
|
{
|
|
if (typed.Length >= width)
|
|
{
|
|
continue;
|
|
}
|
|
var retry = store.Search(kind, typed.PadLeft(width, '0'));
|
|
if (retry.Count > 0)
|
|
{
|
|
found = retry;
|
|
padded = typed.PadLeft(width, '0');
|
|
TermBox.Text = padded;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
PatientList.ItemsSource = found;
|
|
PatientCountText.Text = found.Count switch
|
|
{
|
|
// 0건일 때 <b>무엇으로 찾았는지</b> 말한다 — 유형이 틀려서 0건인 경우가 가장 흔한데
|
|
// 전에는 그 힌트가 화면에 하나도 없어 접속이나 권한을 의심하게 됐다
|
|
0 => $"환자 — 없습니다({KindLabel(kind)}로 찾았습니다)",
|
|
_ when padded.Length > 0 => $"환자 {found.Count}명 — 자릿수를 채워 '{padded}' 로 찾았습니다",
|
|
PatientVisitStore.DefaultLimit =>
|
|
$"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요",
|
|
_ => $"환자 {found.Count}명",
|
|
};
|
|
if (found.Count > 0)
|
|
{
|
|
PatientList.SelectedIndex = 0;
|
|
PatientList.Focus();
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
DialogService.Notify(DialogKind.Error, "환자 검색", "검색 중 오류가 발생했습니다.", ex.Message, this);
|
|
}
|
|
finally
|
|
{
|
|
SearchButton.IsEnabled = true;
|
|
Mouse.OverrideCursor = null;
|
|
}
|
|
}
|
|
|
|
private void OnPatientChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
|
{
|
|
UpdateChosen();
|
|
if (store is null || PatientList.SelectedItem is not PatientSummary patient)
|
|
{
|
|
return;
|
|
}
|
|
try
|
|
{
|
|
Mouse.OverrideCursor = Cursors.Wait;
|
|
var visits = store.ListVisits(patient.ChtNum);
|
|
VisitList.ItemsSource = visits;
|
|
VisitCountText.Text = visits.Count == 0 ? "내원 — 없습니다" : $"내원 {visits.Count}건";
|
|
if (visits.Count > 0)
|
|
{
|
|
VisitList.SelectedIndex = 0;
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
DialogService.Notify(DialogKind.Error, "내원 조회", "내원을 읽지 못했습니다.", ex.Message, this);
|
|
}
|
|
finally
|
|
{
|
|
Mouse.OverrideCursor = null;
|
|
}
|
|
}
|
|
|
|
private void OnVisitChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
|
=> UpdateChosen();
|
|
|
|
/// <summary>고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다</summary>
|
|
private void UpdateChosen()
|
|
{
|
|
var patient = PatientList.SelectedItem as PatientSummary;
|
|
var visit = VisitList.SelectedItem as VisitSummary;
|
|
SelectButton.IsEnabled = patient is not null && visit is not null;
|
|
ChosenText.Text = (patient, visit) switch
|
|
{
|
|
(null, _) => "환자를 찾아 내원을 고르세요.",
|
|
({ } p, null) => $"{p.Name}({p.ChtNum}) — 내원을 고르세요.",
|
|
({ } p, { } v) => $"{p.Name}({p.ChtNum}) · {v.Kind} {v.AcceptedAtText} · 내원 {v.ComNum:F0}",
|
|
};
|
|
}
|
|
|
|
private void OnSelect(object sender, RoutedEventArgs e)
|
|
{
|
|
if (PatientList.SelectedItem is not PatientSummary patient
|
|
|| VisitList.SelectedItem is not VisitSummary visit)
|
|
{
|
|
return;
|
|
}
|
|
if (contexts is null)
|
|
{
|
|
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
|
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
|
|
return;
|
|
}
|
|
var edge = EdgeCombo.SelectedIndex == 1 ? VisitEdge.First : VisitEdge.Last;
|
|
try
|
|
{
|
|
Mouse.OverrideCursor = Cursors.Wait;
|
|
var context = contexts.Load(visit.ComNum, edge);
|
|
if (context is null)
|
|
{
|
|
// 목록에는 있는데 다시 읽을 때 없다 — 그 사이에 지워졌거나 권한이 다르다.
|
|
// 창을 닫지 않는다. 닫으면 미리보기가 조용히 빈 종이를 낸다.
|
|
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
|
"이 내원의 정보를 읽지 못했습니다.",
|
|
$"내원 {visit.ComNum:F0} 을 다시 읽을 수 없습니다. 목록을 새로 검색해 보세요.", this);
|
|
return;
|
|
}
|
|
Picked = context;
|
|
PickedLabel = $"{patient.Name}({patient.ChtNum}) · {visit.Kind}"
|
|
+ $" {visit.AcceptedAtText} · 기준 {Readable(context.AdpDtm)}";
|
|
DialogResult = true;
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
DialogService.Notify(DialogKind.Error, "환자 미리보기",
|
|
"환자 정보를 읽는 중 오류가 발생했습니다.", ex.Message, this);
|
|
}
|
|
finally
|
|
{
|
|
Mouse.OverrideCursor = null;
|
|
}
|
|
}
|
|
|
|
private static string Readable(string stamp)
|
|
=> stamp.Length == 12
|
|
? $"{stamp[..4]}-{stamp[4..6]}-{stamp[6..8]} {stamp[8..10]}:{stamp[10..12]}"
|
|
: stamp;
|
|
#endregion
|
|
}
|