환자를 골라 태그에 값이 들어간다 — 데이터 계층만 있던 것을 화면에 붙인다

전 커밋까지 환자 조회·시각 판정·권한 규칙은 다 있었지만 화면에 붙은 것이
하나도 없었다. 미리보기를 열면 환자 선택이 없고, 태그는 전부 사유만 찍혔다.

## 만든 사슬

PatientPickerDialogView(모달) → PatientContextStore(문맥 5행)
→ PatientTagResolver(태그 → 컬럼) → PreviewWindow.UsePatient → 종이

모달로 둔 이유: 30초 안에 끝나는 자족적인 일이고 권한 문이 걸린 동작이다.
사이드 패널로 두면 실제 환자 정보를 꺼내는 창이 항상 열려 있게 되는데
이 창은 열려 있는 것 자체가 비용이다.

환자와 내원을 한 화면에 둔다. 고르는 대상은 환자가 아니라 내원이다 —
문맥 5행이 전부 ComNum + AdpDtm 으로 읽히므로 환자만 골라서는 아무것도 못 읽는다.

기준 시점(마지막/처음)을 화면에 노출한다. 입원 내원의 진료과·병실·자격 값을
실제로 바꾸는 값이라 숨기면 값이 왜 다른지 설명할 수 없다.

문맥 읽기를 창 안에서 한다. 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고
그때는 이미 고른 것이 사라진 뒤다.

## 매핑은 확인된 6종만

PAT_이름 / PAT_차트번호 / PAT_주민번호 / PAT_휴대전화 / PAT_전화번호 / PAT_주소.
전부 레거시 조회 SQL 의 SELECT 목록에서 컬럼을 직접 확인한 것이다.

PAT_나이·PAT_성별은 넣지 않았다 — UDF_GETAGE·UDF_GETSEX 를 거치는 값이라
컬럼을 짐작해 넣으면 값이 조용히 틀린다. 종이에 그럴듯한 값이 찍히면
아무도 의심하지 않으므로 빈칸보다 나쁘다.

안 옮긴 환자 태그는 "아직 옮기지 않았습니다"라고 말한다.
"DB 접속이 없습니다"로 뭉개면 환자를 골랐는데도 그 사유가 나와 접속을 의심하게 된다.

## 문맥 조회를 한 번에 다섯 번 읽는다

레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다(각각 별도 왕복).
태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.

## 개인정보

- 감사 로그는 고른 직후 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
- 로그에 환자 이름을 남기지 않는다(내원번호로 추적 가능하고, 로그가
  개인정보를 들고 있으면 로그 자체가 위험물이 된다).
- 진단 리포트도 값 대신 길이만 적는다.
- 권한 문은 수정(TK_MODIFY)과 같은 범위다. 넓히면 서식생성기가 환자 조회 도구가 된다.
- 화면에서 감사 기록 사실을 말한다.

## 게이트가 잡은 것 셋

① --db-patient 가 "전건 통과"인데 ⑧~⑬ 이 통째로 SKIP 이었다.
   첫 환자에게 내원이 0건이라 문맥 조회를 한 번도 안 돌린 채 초록불이었다.
   내원 있는 환자를 30명까지 훑고, 못 찾으면 SKIP 이 아니라 FAIL 로 바꿨다.

② 그렇게 찾은 내원에서 CodInf·CoiInf·CowInf 가 셋 다 0행이었다.
   0 을 적어 두는 것으로는 "조건이 어긋났다"와 "행이 없다"를 못 가른다.
   조건 없는 원본 행을 함께 찍게 하니 후자였다(1988년 내원, 행 자체가 없음).

③ 그러면 기간 조회 세 갈래는 아직 한 번도 행을 돌려준 적이 없다.
   안 돌아본 갈래는 통과한 갈래가 아니므로, 각 표에서 직접 내원을 찾아 들어가
   BETWEEN·MtiSeq 조건이 실제로 행을 뽑는 것까지 확인하게 했다(⑮).

--edit-smoke 판정 7건 추가. 전부 대조군이 있다 —
환자 없이 같은 종이를 찍어 사유가 나오는 것을 먼저 확인한 뒤 값으로 바뀌는 것을 본다.
미리보기 창 배선도 따로 본다(붙이면 값, 떼면 사라짐). 그래서 고르는 일과
붙이는 일을 나눠 두었다 — 모달에 묻어 두면 "골랐는데 종이가 그대로"를 자동으로 못 잡는다.

## 게이트

- dotnet test 312/312
- --edit-smoke 실패 0 (환자·미리보기 판정 7건 추가)
- --dialog-shots FAIL 0 (08b-patient-picker 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 (변화 없음)
- --db-smoke 1,271건 diff 0
- --db-patient ①~⑮ 전건 통과 (기간 조회 3갈래 실제 통과 확인)
- --db-modify-smoke S999 8/8 (⑤ 는 E_SctMst 0행으로 여전히 SKIP)

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-18 15:13:48 +09:00
co-authored by Claude Opus 5
parent be9889ed4e
commit 88dab2edcf
11 changed files with 1008 additions and 6 deletions
@@ -453,6 +453,126 @@ public static class EditSmoke
&& withResolver.Contains("환자", StringComparison.Ordinal)
&& withResolver.Contains("[", StringComparison.Ordinal),
withResolver);
// ── 환자 문맥을 붙였을 때 ──
// 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고,
// 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다").
// DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다.
static SheetMe.Data.Stores.PatientContext Fake(string chart, string name)
{
var patInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATCHTNUM"] = chart,
["PATNAM"] = name,
};
var blank = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new SheetMe.Data.Stores.PatientContext(
90001m, "202608181500", patInf, blank, blank, blank, blank);
}
var patientDoc = new FormDocument();
var patientPage = new FormPage { Width = 720, Height = 856 };
patientDoc.Pages.Add(patientPage);
// 넷을 한 종이에 둔다: 매핑된 것 둘, 매핑 안 된 환자 태그 하나, 시각 태그 하나.
// 하나만 두면 "값이 나온다"만 보고 나머지 갈래가 조용히 깨져도 모른다.
var probes = new[]
{
("차트", "PAT_차트번호"), ("이름", "PAT_이름"),
("나이", "PAT_나이"), ("시각", "ETC_현재일자"),
};
for (var i = 0; i < probes.Length; i++)
{
var probe = new ControlElement
{
Type = "TextBox", Id = probes[i].Item1,
Bounds = new LayoutRect { X = 40, Y = 40 + (i * 40), W = 260, H = 20 },
};
probe.Props.SetText("Text", probes[i].Item2);
probe.Props.SetText("DataInterfaceTag", probes[i].Item2);
patientPage.Controls.Add(probe);
}
var patientVm = new DesignerViewModel(patientDoc);
string PatientPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver)
{
var visual = (System.Windows.FrameworkElement)
Services.PrintService.BuildPageVisual(patientVm.Pages[0], true, resolver);
visual.Measure(new System.Windows.Size(720, 856));
visual.Arrange(new Rect(0, 0, 720, 856));
visual.UpdateLayout();
return CollectText(visual);
}
// 대조군 — 환자 없이. 이 줄이 없으면 아래 판정은 "원래 그랬던 것"과 구분되지 않는다.
var withoutPatient = PatientPaper(new StubTagResolver());
Check("환자 태그(대조군): 환자가 없으면 값이 아니라 사유다",
!withoutPatient.Contains("C-9001", StringComparison.Ordinal)
&& withoutPatient.Contains("[PAT_차트번호", StringComparison.Ordinal)
&& withoutPatient.Contains("[PAT_이름", StringComparison.Ordinal),
withoutPatient);
var withPatient = PatientPaper(
new Services.PatientTagResolver(Fake("C-9001", "가상환자"), new StubTagResolver()));
Check("환자 태그: 문맥을 붙이면 값이 찍힌다",
withPatient.Contains("C-9001", StringComparison.Ordinal)
&& withPatient.Contains("가상환자", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_차트번호", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_이름", StringComparison.Ordinal),
withPatient);
// 아직 안 옮긴 환자 태그는 <b>그렇다고 말해야</b> 한다.
// "DB 접속이 없습니다" 로 뭉개면 환자를 골랐는데도 그 사유가 나와 사람이 접속을 의심한다.
Check("환자 태그: 매핑 없는 환자 태그는 안 옮겼다고 말한다",
withPatient.Contains("PAT_나이", StringComparison.Ordinal)
&& withPatient.Contains("아직 옮기지", StringComparison.Ordinal),
withPatient);
// 사슬이 끊기지 않았는가 — 환자를 붙였다고 시각 태그가 죽으면 안 된다
Check("환자 태그: 앞 해석기(시각·사용자)가 계속 산다",
withPatient.Contains("2026-08-18", StringComparison.Ordinal), withPatient);
// 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면
// "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다.
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
// 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다.
var patientPreview = new Views.PreviewWindow(patientVm);
patientPreview.Show();
string PreviewPaper()
{
patientPreview.UpdateLayout();
return CollectText(patientPreview);
}
var previewBefore = PreviewPaper();
patientPreview.UsePatient(Fake("C-9003", "창환자"), "표본");
var previewAfter = PreviewPaper();
Check("미리보기 창: 환자를 붙이면 종이가 값으로 바뀐다",
!previewBefore.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("창환자", StringComparison.Ordinal),
$"붙이기 전 {previewBefore.Contains("C-9003", StringComparison.Ordinal)}"
+ $" / 붙인 뒤 {previewAfter.Contains("C-9003", StringComparison.Ordinal)}");
// 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다.
// 미리보기 창은 하나만 뜨므로 그 잔상은 <b>다음 사람이</b> 보게 된다.
patientPreview.UsePatient(null, string.Empty);
var previewCleared = PreviewPaper();
Check("미리보기 창: 환자를 떼면 값이 사라진다",
!previewCleared.Contains("C-9003", StringComparison.Ordinal)
&& previewCleared.Contains("[PAT_차트번호", StringComparison.Ordinal),
previewCleared);
patientPreview.Close();
var emptyName = PatientPaper(
new Services.PatientTagResolver(Fake("C-9002", string.Empty), new StubTagResolver()));
Check("환자 태그: 값이 비면 사유를 밝힌다",
emptyName.Contains("C-9002", StringComparison.Ordinal)
&& emptyName.Contains("PatNam", StringComparison.Ordinal)
&& emptyName.Contains("비어", StringComparison.Ordinal),
emptyName);
}
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)