사용자가 콕 집어 "레이어 구분이 잘 안 된다"고 했다. 원인은 색이 아니라 <b>산수</b>였다.
## 부모와 자식이 같은 x 에 그려지고 있었다
계층을 나타내는 수단은 들여쓰기 하나뿐인데(평탄한 ListBox 다), 그 폭이
셰브론 폭에 정확히 상쇄되고 있었다.
- 접기 셰브론은 폭 16, 자식이 없으면 Collapsed → <b>폭이 통째로 사라진다</b>
- 컨테이너 자식 들여쓰기는 정확히 16
그래서 패널의 아이콘과 그 <b>직속 자식</b>의 아이콘이 같은 x 에 놓였다.
목록만 보면 부모-자식이 아니라 형제로 읽힌다. 3단 중첩에서는 어느 행이 누구 밑인지
따라갈 수가 없다. 셰브론 자리를 항상 남기도록 고쳤다 — 이제 깊이 한 단계가 16px 사다리로 보인다.
## 접힌 컨테이너가 무엇을 품었는지 말하지 않았다
페이지 행에는 (7), 그룹 폴더에는 (3)이 이미 붙는데 컨테이너만 없었다.
접힌 패널이 3개를 품었는지 300개를 품었는지 펴 보기 전에는 알 수 없었고,
그 패널을 지우거나 옮기기 전에 무엇이 함께 가는지도 알 수 없었다.
중첩까지 세어 붙인다 — 그 수가 곧 "이걸 지우면 몇 개가 같이 지워지나"다.
## 고른 컨테이너의 범위가 안 보였다
캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
그룹을 통째로 고르면 멤버가 네이비 밴드로 묶이는 기구가 <b>이미 있었는데</b>
컨테이너에는 그 갈래가 없었다. 조상 중에 선택된 컨테이너가 있으면 같은 밴드를 준다.
그룹 판정보다 뒤에 둬서 기존 그룹 색을 이기지 않는다.
## 판정
- 단위 시험 388 · edit-smoke 384건 전건 통과 (레이어 4건 추가)
- --dialog-shots 69장 전건 통과 (레이어 샷 4종을 눈으로 대조 — 사다리·개수·밴드 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
## 아직 남은 것
행 높이가 1줄/2줄로 갈린다 — 표시명이 Id 와 다를 때만 둘째 줄(Id)이 뜨기 때문이다
(ControlViewModel.LayerId). 목록을 훑을 때 리듬이 깨지는데, 고치려면 '항상 두 줄'(공간 낭비)이나
'한 줄에 합치기'(정보 밀도 변화) 중 하나를 골라야 해서 이번 범위에 넣지 않았다.
캔버스에서 고른 것을 목록이 따라 스크롤·펼치는 것(조사에서 medium 으로 나온 항목)도 남았다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
구조 사용성 조사를 돌렸더니 가장 무거운 것이 사용성이 아니라 <b>버그</b>였다.
## 무슨 일이었나
배선 실행기가 데이터소스를 <b>레거시 클래스명</b>으로 찾고 있었다.
if (control.Type == "MDataTable")
그런데 서식을 XML 에서 읽는 순간 LegacyTypeCatalog:45 가 그 이름을 중립 이름
"DataTable" 로 바꿔 둔다. 즉 실사용 서식에서는 이 조건이 <b>한 번도 참이 되지 않는다</b> —
데이터소스를 한 개도 못 모은다.
결과는 조용하다. 배선을 옳게 걸어도 모든 DataTableField 가
"데이터소스 X 이 이 서식에 없습니다"로 실패해 미리보기와 <b>인쇄</b>에서 빈칸이 되고,
미리보기의 '데이터소스...' 버튼은 영영 회색이다(Names.Count 가 0이라).
사용자 입장에서는 "배선을 걸었는데 값이 안 나온다" 뿐이고 이유를 알 방법이 없다.
## 왜 여태 못 봤나
진단 네 곳이 전부 ControlElement 를 <b>손으로</b> 만들면서 같은 레거시 이름을 썼다
(EditSmoke:395,739 · DialogShots:465 · DbSmoke:2269). 러너의 오타와 진단의 오타가 짝이 맞아
"배선: 서식에서 데이터소스 이름을 모은다" 가 계속 초록이었다.
<b>로더를 우회해 만든 문서로 로더의 계약을 검사하고 있었다</b> — 검사가 실사용과 다른 세계를 보고 있었다.
## 고친 것
① 러너가 중립 타입명으로 비교한다. 문자열은 LegacyTypeCatalog.DataTableType 상수 하나로 모았다 —
두 이름이 공존하는 한 각자 적으면 같은 일이 또 난다.
② 진단 네 곳이 같은 상수를 쓰게 했다.
③ 실사용과 <b>같은 경로</b>로 만드는 검사를 새로 넣었다 — 팔레트 경로(AddControlAt("DataTable"))로
데이터소스를 만들고 러너가 그것을 찾는지, 배선이 '이 서식에 없습니다'로 떨어지지 않는지 본다.
## 고치기 전이었으면 잡혔는지 확인했다
러너 한 줄만 옛 비교로 되돌려 돌려 봤다 — 새 검사 2건과 <b>기존 검사 1건</b>이 실패했다.
기존 검사가 실패한 것은 ②로 진단이 더 이상 오타를 복제하지 않기 때문이다.
이 세 줄이 앞으로 같은 어긋남을 막는다.
## 판정
- 단위 시험 388 · edit-smoke 380건 전건 통과(배선 3건 추가)
- --dialog-shots 69장 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
(그 게이트는 PageView 직접 렌더라 해석기를 타지 않는다 — 이 수정과 무관하다는 뜻이기도 하다)
DB 가 붙은 단말에서 실제 배선이 값을 내는지는 --db-patient 계열로 별도 확인이 필요하다.
이 커밋이 고친 것은 "데이터소스를 찾는 것"까지다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
앞 커밋에서 남겨 둔 것 — 타이틀바는 통일했지만 내용 여백은 창마다 달랐다.
실측하니 12(여섯 창) · 14(데이터소스 배선) · 16(서식 등록)으로 갈려 있었다.
설명 문장이 창 가장자리에 바짝 붙는 창이 여럿이었고, 타이틀바가 생기면서 그 대비로 더 드러났다.
16 으로 맞춘다. 12 는 36px 타이틀바 아래에서 답답하고, 기준으로 삼은 MessageDialogView 는
본문에 20,18,20,14 를 쓴다 — 그 사이에서 큰 창(1180×760 쿼리 편집기)의 내용을
밀어내지 않는 값이 16 이다. 잘림 검사(dialog-shots 69장)가 전건 통과하는 것으로 확인했다.
## 거짓이 된 주석
태그 선택 창에 이렇게 적혀 있었다 —
"창 껍데기는 OS 크롬을 유지한다. … 이 앱의 대화상자 10종이 전부 OS 크롬이라
이 창만 바꾸면 크롬 규약이 두 벌이 된다."
앞 커밋에서 열한 창을 전부 자체 크롬으로 옮겼으므로 그 전제가 무너졌다.
그대로 두면 다음 사람이 "이 창은 일부러 OS 크롬"이라고 읽고 되돌린다.
무엇이 왜 바뀌었는지까지 적어 둔다 — 주석이 틀렸다는 사실 자체가 정보다.
## 판정
- 단위 시험 388 · edit-smoke 377건 전건 통과
- --dialog-shots 69장 전건 통과(잘림 없음, 다크·라이트 눈으로 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
지적을 받고서야 실제로 봤다. 앞 커밋에서 "제목줄의 환자 아이콘 버튼은 손대지 않았다 —
옆 아이콘들과 같은 스타일·크기다" 라고 썼는데, <b>마크업만 읽고 화면을 안 봤다.</b>
스타일과 크기는 같았지만 색이 달랐다.
## 무슨 일이었나
이웃 아이콘(복사·삭제·테마·저장)은 XAML 컨버터를 타고, 그 기본 브러시 키가 <b>B.Muted</b> 다.
환자 아이콘만 코드에서 만드는데 안 고른 상태에 <c>null</c> 을 넘겼고,
LucideIcons.Paint 는 키도 색도 없으면 <c>Brushes.Black</c> 으로 칠했다 —
어두운 제목줄에서 <b>안 보이는 아이콘</b>이라는 뜻이다.
컴파일러가 CS8604 로 그 자리(MainView.xaml.cs:139)를 이 세션 내내 경고하고 있었다.
빌드 로그에서 매번 지나쳤다.
## 두 군데를 고친다
① 호출부 — 안 고른 상태의 키를 "B.Muted" 로. 이웃과 같은 토큰이라 테마 전환도 따라간다.
② 기본값 자체 — 색도 키도 없으면 이제 B.Muted 리소스 참조로 떨어진다.
검정은 다크 테마에서 아이콘을 숨기는 기본값이라 함정으로 남겨 둘 이유가 없다.
null 을 넘기던 호출부는 ①이 유일했으므로 다른 곳의 색은 바뀌지 않는다.
## 확인 방법도 고쳤다
이번에는 제목줄을 잘라 3배로 키워 다크·라이트 양쪽을 눈으로 대조했다.
전에는 대화상자 사진만 보고 제목줄은 마크업으로 판단했다 — 그래서 놓쳤다.
회귀 방지로 edit-smoke 에 "아이콘 기본색이 하드코딩 검정이 아니다" 를 넣는다.
## 판정
- 단위 시험 388 · edit-smoke 377건 전건 통과 (아이콘 색 1건 추가)
- CS8604 경고 소멸
- --dialog-shots 69장 · --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
창 자체는 앞 커밋의 공통 크롬을 그대로 받아 타이틀바가 붙었다. 확인하다 두 가지가 걸렸다.
## '찾기(_F)' 가 밑줄 대신 글자 그대로 나왔다
버튼 템플릿의 ContentPresenter 에 RecognizesAccessKey 가 없었다.
그래서 화면에 <b>"찾기(_F)"</b> 가 그대로 찍히고 Alt+F 도 먹지 않았다.
접근키를 쓰는 버튼이 앱 전체에서 이 하나뿐이라 20년째 아무도 안 밟은 자리다.
일반 버튼 템플릿 셋에만 켠다. 태그 제안 칩(SuggestChip)은 자기 템플릿이 따로 있어
걸리지 않는데, <b>거기서 켜면 안 된다</b> — 칩 내용이 데이터 바인딩이라
PAT_이름 같은 태그명이 "PAT이름"으로 잘려 보인다. 버튼 중 데이터를 싣는 것은 그것뿐이다.
## 비활성 강조 버튼의 글자가 배경에 묻혔다
'선택' 버튼은 환자를 고르기 전까지 비활성이다. 앞 커밋에서 이 버튼에 Primary 를 주면서
드러났는데, Primary 의 비활성 처리가 <b>불투명도만 0.45 로</b> 내리는 것이었다.
강조색이 옅어질 뿐 여전히 파랗고, 그 위의 흰 글자는 옅어진 파랑에 묻힌다 —
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"가 겹친다.
비활성은 색을 빼는 쪽으로 바꿨다(중립 채움 + 흐린 글자).
Primary 를 쓰는 모든 창이 같이 얻는다.
## 판정
- 단위 시험 388 · edit-smoke 376건 전건 통과
- --dialog-shots 69장 전건 통과 (다크·라이트 양쪽에서 눈으로 확인)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
제목줄의 환자 아이콘 버튼은 손대지 않았다 — 옆 아이콘들과 같은 스타일·크기(Subtle 34×28)이고
고른 상태를 아이콘 색으로 알리는 것도 그대로다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
## 창 열둘 중 하나만 우리 옷을 입고 있었다
커스텀 크롬을 가진 것은 메인 셸과 MessageDialogView 둘뿐이었고,
나머지 열한 창(서식 이력·데이터소스 배선·글꼴 관리자·태그 선택·쿼리 편집기·마스크·
환자 선택·데이터소스 결과·레이어 필터·서식 등록·미리보기)은 <b>윈도우 기본 타이틀바</b>를 달고 떴다.
다크 테마에서 어두운 앱 위에 OS 바가 얹히고, 높이·글자·모서리·닫기 버튼 모양이 전부 달라
"다른 프로그램이 열렸다"로 보였다.
열한 창이 이미 전부 ThemedWindow 를 걸고 있었으므로, 그 옆에 템플릿을 가진
ThemedDialogWindow 를 만들고 각 창은 <b>스타일 이름 한 줄</b>만 바꿨다.
창마다 타이틀바 markup 을 복사했다면 다음에 또 갈라진다.
## WindowChrome 은 스타일로 줄 수 없었다
두 번 막혔고 둘 다 같은 뿌리다 — WindowChrome 은 Freezable 인데 창에 붙으면서
스스로 속성을 고친다. Setter.Value 에 두면 열한 창이 <b>한 인스턴스를 나눠 쓰다</b>
두 번째 창부터 "읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽었다.
x:Shared="False" 로도 안 됐다 — 공유되는 것은 리소스가 아니라 Setter 가 이미 붙든 값이다.
그래서 ctl:DialogChrome 첨부 속성이 창마다 새 인스턴스를 만들어 붙인다.
템플릿 안 닫기 버튼은 Click 핸들러를 가질 수 없으므로(템플릿에 코드비하인드가 없다)
표준 SystemCommands.CloseWindowCommand 를 쓰고 그 CommandBinding 도 여기서 단다.
DialogResult 를 건드리지 않으므로 ShowDialog 는 false 를 돌려주고 호출부의 '취소' 경로가 그대로 탄다.
최대화 보정도 같이 건다. WindowStyle=None 인 창은 최대화하는 순간 작업표시줄을 덮는데
(메인 창에서 겪은 결함이다) 대화상자 열 개가 크기 조절 가능이라 타이틀바 더블클릭으로
그 상태에 들어갈 수 있다.
## 진단이 바로 그 부분에 눈이 없었다
--dialog-shots 는 window.Content 만 찍고 있었다. 그래서 이 결함을 <b>한 번도 보여 준 적이 없다</b> —
MessageDialogView 만 타이틀바가 사진에 나왔는데, 그건 그 창의 바가 Content 안에 있어서였다.
창 전체를 찍도록 바꿨다(템플릿의 PART_Root). 고칠 대상이 사진에 안 나오는 진단은
있으나 마나였고, 실제로 이번에도 첫 캡처가 "고쳤는데 그대로"로 보여 한 번 헛짚게 만들었다.
## 강조 버튼 — 규칙은 이미 있었다
Primary 스타일을 열한 창 중 넷만 쓰고 있었다. 그런데 IsDefault="True" 는 대부분의 창이
이미 주 동작에 달아 두었다 — 규칙을 새로 만들 필요가 없었다.
<b>기본 버튼 = 강조</b>로 맞춘다(서식 이력·서식 등록·마스크·레이어 필터·데이터소스 결과·환자 선택).
글꼴 관리자만 예외로 손으로 골랐다. IsDefault 가 없고 버튼이 셋인데,
강조는 <b>고른 것만</b> 바꾸는 '선택 조합에 적용'에 준다. '전체에 적용'은 문서 전체를
한 번에 바꾸므로 눈이 먼저 가는 자리에 두지 않았다 — 되돌리기가 있어도
되돌릴 생각을 하려면 먼저 알아채야 한다.
환자 선택의 '선택'에는 Primary 만 주고 IsDefault 는 건드리지 않았다 —
검색칸에서 Enter 는 '찾기'여야 한다.
## 판정
- 단위 시험 388 · edit-smoke 376건 전건 통과
- --dialog-shots 69장 전건 통과(창 전체 캡처로 바뀐 뒤에도)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
## 남은 것
내용 여백은 아직 창마다 다르다 — 설명 문장이 창 위쪽 가장자리에 바짝 붙는 창이 여럿이다.
타이틀바가 생겨 덜 도드라지지만 통일된 것은 아니다. 별도로 잡을 것.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1순위가 '틀린 것 고치기'였다면 여기는 <b>이미 있는 명령에 닿는 길</b>이다.
새 패널·새 화면 없이 커맨드·단축키·메뉴 층에서 끝난다.
## 선택 확장 4종 — 정렬 8종의 실효 사용률을 통째로 끌어올린다
정렬 6종·간격 4종·같은 크기가 다 있는데, 그 명령들이 전제하는 '올바른 선택'을
만들 수단이 없었다. 표 서식에서 가장 흔한 편집이 "이 행의 입력칸 8개를 조금 높이고
아래로 붙이자"인데, 그 8개를 고르는 길이 <b>Ctrl+클릭 8번뿐</b>이다.
마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 온다 —
실측 787건에서 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개·p90 41개다.
즉 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 앞에 붙었다.
주 선택 기준으로 같은 행/열/종류/크기인 <b>형제</b>를 선택에 더한다(Alt+1~4, 우클릭 메뉴 맨 위).
범위를 형제로 한 것은 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있어서다.
2px 오차를 허용한다. 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡히고,
8개 중 5개를 정렬해 놓고 나머지가 남은 것을 못 알아채는 것이 가장 나쁜 결과다.
격자 크기에 묶지 않았다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
잠긴 것은 뺀다. 편집하려고 넓히는 선택인데 안 움직이는 것이 섞이면
정렬을 눌렀을 때 "왜 이건 그대로지"가 된다.
## 줌 단축키 — 커맨드는 처음부터 있었고 키만 없었다
8pt 글자를 확인하려 200% 로 올렸다가 전체 배치를 보려면, 캔버스에서 손을 떼고
<b>화면 맨 아래 가운데</b> 플로팅 바의 배율 버튼을 눌러 팝업을 열고 '화면 맞춤'을 골라야 했다.
확대·축소조차 Ctrl+휠뿐이라 트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.
Ctrl+0(100%) · Ctrl+1(화면 맞춤) · Ctrl+± 를 <b>창</b> InputBindings 에 건다.
캔버스 키 처리기에 두면 안 된다 — 그쪽은 포커스가 TextBox/ComboBox 면 통째로 비켜서므로
인스펙터 값을 고친 직후에 줌 키가 죽는다.
## Ctrl+방향키로 크기 — 위치에만 있던 미세 조정의 짝
입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력했다 —
캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이 화면 6px 이라
작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.
넛지와 같은 400ms 코얼레스를 쓴다. 선은 LineGeometry 를 태워 두께 축이 늘어나지 않게 막는다 —
허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.
## 잠금·숨김을 캔버스에서, 그리고 잠긴 것을 보이게
배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때, 지금은 좌측 패널을 레이어 탭으로
바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 했다. 마퀴로 40개를 잡아 놔도
한 번에 잠글 수단이 없었다. Ctrl+L / Ctrl+Shift+H 와 우클릭 메뉴를 붙인다.
<b>표시가 없던 것이 더 문제였다.</b> 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어,
클릭도 드래그도 안 되는 이유가 화면에 없었다 — "왜 이것만 안 잡히지"만 남는다.
점선 테두리를 <b>오버레이 층</b>에 그린다. 종이 층(PageView)에 넣으면
db-render 게이트가 그것을 찍어 md5 가 깨진다 — 레거시와의 픽셀 동일성을 잠그는 게이트다.
메뉴 이름은 '선택 잠그기'로 한정했다. <b>해제는 캔버스에서 못 한다</b> —
히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다. 해제 경로는 레이어 패널이다.
## Ctrl+\ — 유일한 레이아웃 변경
좌 248 + 스플리터 10 + 우 300 = 558px 가 <b>항상</b> 점유된다. MinWidth 180/250 때문에
스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없다.
좌우를 <b>함께</b> 접는다. 개별 토글 두 개를 두지 않았다 — 목적이 하나라 키도 하나여야 손이 기억한다.
MinWidth 도 함께 0 으로 내려야 실제로 사라진다(Width=0 만으로는 최소폭이 이긴다).
펼친 폭은 따로 기억한다 — 접힌 0 을 사용자가 정한 폭으로 착각하면 다음에 못 되돌린다.
F4(인스펙터 진입)를 누르면 접힌 인스펙터를 먼저 편다.
레이아웃은 이것 말고 바꾸지 않았다. 좌 248 / 우 300 은 이미 측정된 값이고
(레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이
0 스크롤·0 클릭이다. 컨트롤 1,000개짜리 서식에서 트리와 속성을 동시에 봐야 하는 작업이라
떠 있는 패널로 바꾸면 겹침·재배치 비용만 생긴다.
## 판정
- 단위 시험 388
- edit-smoke 376건 전건 통과 (선택 확장 7건 · 크기 조절 2건 · 잠금 5건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변 (잠금 표시가 종이에 안 나온다는 증거)
- <b>--cleartype 은 이번에 판정 불가다</b> — 게이트가 스스로 "캡처 실패, 색 픽셀 0" 이라고 보고한다.
이전 커밋에서 stash 로 되돌려 돌려도 같은 결과라 이 변경 탓이 아니다(환경 문제).
화면 캡처가 되는 상태에서 다시 확인할 것.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
사용성 감사를 돌렸더니 결론이 뜻밖이었다. <b>없는 기능이 문제가 아니었다</b> —
정렬 6종·간격 4종·같은 크기·그룹·스냅 가이드·레이어 트리·일괄 편집이 이미 다 있다.
진짜 문제는 있는 것이 틀리게 동작하고, 그 실패가 화면에 아무 흔적을 남기지 않는 것이었다.
넷 다 사용자가 무엇을 잃었는지 모르는 채로 저장하는 경로다.
## ① Ctrl+Shift+Z 가 다시 실행이 아니라 되돌리기였다
CanvasKeyboardBehavior 의 `case Key.Z when ctrl` 에 shift 검사가 없었다.
바로 아래 Key.G 는 ctrl && shift 를 가르는데 Z 만 빠져 있었다.
두 번 되돌린 뒤 한 단계 물리려 Ctrl+Shift+Z 를 누르면 <b>세 단계 전</b>으로 간다.
방향이 반대인 줄 모르면 두세 번 더 눌러 편집분을 통째로 날리는데,
되돌리기는 언제나 성공하므로 경고가 없다. 레이어 패널 쪽도 같은 짝이라 함께 고쳤다.
## ② Ctrl+드래그 복사를 Esc 로 취소하면 사본이 남았다
이번 세션에 Ctrl+드래그 복사를 넣으면서 만든 회귀다. CancelDrag 는 좌표만 되돌리는데,
사본은 이미 문서에 들어가 있으므로 원래 자리로 되돌리면 <b>원본과 픽셀 단위로 겹친다</b>.
화면은 취소된 것처럼 보이고, 그대로 저장하면 종이에 같은 글자가 두 번 인쇄된다 —
증거는 "조금 굵어 보인다" 뿐이다.
복제한 드래그였으면 좌표 복원 대신 복제 자체를 물린다. 순서가 중요하다:
Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이 죽은 참조가 된다 —
상태를 먼저 비우고 그다음에 되돌린다. 그리고 DiscardRedo 로 redo 이력도 버린다.
Esc 는 "되돌린다"가 아니라 "없던 일로 한다"이므로, Ctrl+Y 로 되살아나면 취소가 아니다.
## ③ 우클릭이 커서 아래를 잡지 않았다
우클릭 메뉴는 20항목인데 우클릭 <b>선택</b> 핸들러가 저장소 전역에 0건이었다.
라벨 A 를 골라 둔 채 떨어진 체크박스 B 를 우클릭하면 메뉴는 B 위에 뜨는데
[삭제]는 A 를 지운다. 선택이 비어 있으면 항목이 전부 살아 있는 채로 아무 일도 안 한다.
윈도우의 거의 모든 프로그램이 지키는 규약이라 사용자는 메뉴가 가리키는 대상을
확인할 생각조차 하지 않는다 — 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.
이미 선택 안에 있으면 선택을 유지한다(다중 선택 우클릭 정렬 흐름을 깨면 안 된다).
e.Handled 는 두지 않아 기존 ContextMenu 가 그대로 열린다.
<b>함정 하나</b>: HitTestControl 은 잠금·숨김을 히트 후보에서 아예 빼므로,
잠가 둔 배경 괘선 위 우클릭이 '빈 곳'으로 판정돼 선택이 사라진다.
잠금은 편집 보호지 부재가 아니다 — AnyControlAt 을 따로 만들어 그 위에서는 선택을 지우지 않는다.
## ④ 저장 안 된 문서를 화면에서 구별할 수 없었다
UndoService.IsDirty 는 저장 지점 깊이까지 추적해 정교하게 있었는데
(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤, 어느 것이 안 저장됐는지 알 방법이 없다 —
탭 제목이 저장 전후로 글자 하나 다르지 않다. 그래서 안전하게 전부 다시 저장하게 되는데
<b>DB 저장은 새 버전을 만든다</b> — 서식마다 쓸데없는 버전이 쌓인다.
탭에 6px 점을 찍는다. 통지는 값이 <b>바뀔 때만</b> 울린다 — 매번 울리면 드래그 한 번에 수백 번이다.
## 판정
- 단위 시험 388
- edit-smoke 362건 전건 통과 (Esc 취소 4건 · 되돌리기/저장표시 6건 · 잠김 히트 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --dialog-shots · --cleartype 전건 통과
## 감사에서 나온 나머지
2순위(선택 확장 4종·줌 단축키·Ctrl+방향키 크기·잠금 토글)와 3순위는 아직 안 했다.
<b>레이아웃은 바꾸지 않기로 했다</b> — 좌 248/우 300 은 이미 측정된 값이고
(레이어 행 최소치, 인스펙터 X|Y 한 줄 최소치) 라벨 1개 선택 시 자주 쓰는 4종이
0 스크롤·0 클릭이다. 다만 패널을 접을 수단이 없는 것은 실재하는 부재다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
글꼴 칸이 자유 입력 텍스트 하나였다. "돋움"을 매번 손으로 쳐야 했고,
한 글자만 틀려도 레거시는 <b>아무 말 없이</b> 기본 글꼴로 떨어진다 —
디자이너에서는 멀쩡해 보이고 EMR 에서만 다른 글꼴로 나온다.
이제 설치된 글꼴 목록에서 고른다. 목록의 각 이름은 그 글꼴로 그린다 —
이름만으로는 어떤 모양인지 알 수 없다. 페이지 기본 글꼴 칸도 같은 것을 쓴다
(전에는 페이지 쪽만 자유 입력으로 남아 있었다).
## 이름의 형태가 핵심이다
레거시가 저장하는 값은 GDI 가 주는 <b>한글 이름</b>이다 — 실사용 서식 실측:
"굴림, 11.25pt, style=Bold" · "돋움, 9.75pt". 그런데 WPF 의 FontFamily.Source 는
같은 글꼴을 "Gulim" 으로 준다. 영문 이름을 목록에 쓰면 <b>고르는 순간</b> 서식의 글꼴
이름이 바뀌고, 레거시가 그 이름을 못 찾으면 조용히 기본 글꼴이 된다.
그래서 글꼴이 스스로 담고 있는 ko-KR 이름표를 먼저 읽고, 없을 때만 원래 이름을 쓴다.
검사가 이것을 고정한다 — 목록에 "굴림"·"돋움"·"맑은 고딕" 중 하나는 반드시 있어야 한다.
## 목록에 가두지 않는다
병원 PC 와 이 PC 의 설치 글꼴은 다르다. 목록에만 고를 수 있게 하면
여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없고, 칸을 건드리는 순간 사라진다.
그건 서식이 틀린 것이 아니라 <b>지금 이 화면이 병원과 다르게 보인다</b>는 뜻일 뿐이다.
그래서 고르기도 되고 쓰기도 되는 칸으로 두고, 이 PC 에 없는 글꼴이면
값은 그대로 둔 채 ⚠ 로 알린다("저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다").
커밋은 포커스를 잃을 때 한 번이다 — 커밋 한 번이 문서 전체 딥클론 1회라
글자마다 커밋하면 "맑은 고딕" 여섯 자에 스냅샷 여섯 개가 쌓인다.
## 판정
- 단위 시험 388
- edit-smoke 348건 전건 통과 (글꼴 9건 추가 — 한글 이름·저장 형식·없는 글꼴 보존·경고)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --dialog-shots · --cleartype 전건 통과
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
커밋 규약은 그대로다 — BindRow 는 이미 다중 대상이었고(스냅샷 1회 → 선택 전체 순회)
바뀐 것은 "어떤 행을 만드는가"뿐이다. 새 커밋 경로를 만들지 않았다.
## 막혀 있던 곳
타입이 하나라도 다르면 타입 전용 섹션이 통째로 사라졌다(RebuildCore 의 items.All(i => i.Type == type)).
그런데 여러 개를 함께 고쳐야 하는 속성 — 읽기 전용·인쇄 출력·필수 입력·재조회 — 이 정확히
거기 있었고, 없는 이유를 알려 주는 표시도 없어 "이 도구로는 안 되는 일"로 보였다.
더 나쁜 것은 <b>탭</b>이었다. 탭 가시성을 행을 만들기 전에 descriptor 하나로 정하는 바람에
타입이 섞이면 데이터·동작 탭이 접히고 selectedTab 이 디자인으로 강제 복귀했다.
공통 섹션을 완벽하게 만들어도 그 뒤에 갇힌다 — 순서를 바꾸지 않으면 어떤 병합 규칙으로도 못 고친다.
그래서 '① 공통 속성 계산 → ② 탭 → ③ 섹션' 으로 재배치했다.
## 무엇을 공통으로 보는가 — BatchEditPolicy
키 이름이 같은 것으로는 부족하다. Editor·Choices(원소와 순서)·Default 까지 같아야 한 칸에 묶는다.
이 규칙 하나가 예외 코드 없이 함정들을 잡는다.
- 정렬(TextAlign): 라벨은 ContentAlignment 9값, 텍스트박스는 HorizontalAlignment 3값이다.
이름만 보고 묶으면 "MiddleCenter" 가 텍스트박스에 들어가고 레거시는 그것을 읽지 못한다.
- 테두리(BorderStyle): 기본값이 Fixed3D 대 None 이다. 기본값이 갈리면 키가 없는 대상이
<b>남의 기본값</b>으로 보이고, 갈렸는지 판정부터 틀어진다.
- 표시(visible/Visible): 라벨만 소문자다(레거시가 그림자 속성을 쓴다). 키가 달라 자연히 탈락한다.
<b>별칭으로 묶지 않았다</b> — 묶으면 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데
우리 미리보기는 계속 보여 준다.
- 스키마를 모르는 타입(표 등)이 섞이면 빈 집합이다. 무엇이 공통인지 말할 근거가 없다.
그 위에 대상 집합을 보는 차단을 건다. 근거 대부분이 키 이름이 아니라 <b>누가 선택됐는가</b>에 있다.
- 유일해야 하는 것: 이름·서명 슬롯·탭 순서·링크 대상. 서명 슬롯이 같으면 전자동의서가
보호자 칸에 환자 서명을 찍는다 — 종이에는 서명이 다 있어 아무도 의심하지 않는다.
- 남을 이름으로 가리키는 배선: 액션 대상 컨트롤·합산식·이벤트 매핑. 같은 값을 넣으면
5번 문항을 누를 때 1번 문항이 비워지는 식으로 <b>다른 곳</b>이 깨진다.
- 되돌릴 수 없는 것: 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
스냅샷으로 덮이지 않는 유일한 부류라 커밋 자체를 막는다.
- 같은 부모의 라디오 Checked: 레거시 라디오는 GroupName 이 없어 직계 부모가 문항인데,
로더가 자식을 부모에 붙이기 <b>전에</b> 값을 대입해 형제가 꺼지지 않는다.
한 문항에 답이 둘 켜진 채로 저장되고 그대로 열린다. 부모를 모르면 막는 쪽으로 뒀다.
- 선의 방향·굵기: 전용 편집기는 컨트롤마다 크기까지 함께 고치므로 허용하고,
고급(원문) 경로만 막는다 — 거기서는 속성만 바뀌어 XML 은 Vertical 인데 경계는 가로인 선이 된다.
이름 행은 items.Count == 1 을 <b>유지</b>했다. 푸는 것이 목표처럼 보였지만 반대다 —
풀면 두 번째 대상부터 이름이 충돌하는데, 그 거절이 모달을 띄우고도 커밋 루프를 멈추지 못한다.
## 고급(원문) 섹션을 다중 선택에도 열되, 판정을 대상 전체로
전에는 행의 종류·편집 여부·키 목록을 items[0] 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다.
그래서 이미지·중첩·참조 값을 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고,
클릭 순서에 따라 같은 조작이 안전해지거나 파괴적이 됐다.
게다가 GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지 원문이 둘 다 "" 로 접혀
<b>갈렸다는 표시조차 나오지 않았다</b> — 경고 없이 base64 가 문자열로 교체된다.
이제 전부 갖고 전부 문자열일 때만 편집을 연다. 아니면 이유를 밝힌 읽기 전용 행이다
("3개 중 1개에만 있음", "대상마다 값의 형태가 다릅니다"). 없던 대상에 키를 만들면
레거시 서식생성기가 열 때마다 오류 모달을 띄우므로 만들지 않는다.
'속성 추가'는 단일 선택만 — 중복 검사·쓰기가 전부 대상 하나 기준이라 N개 중 1개에만 키가 생긴다.
## 함께 고친 것 — 다중 편집을 넓히기 전에 막아야 했던 것들
**갈린 토글을 한 번 누르면 꺼졌다.** 실측으로 확인했다(진짜 ToggleButton + 진짜 바인딩):
누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False. 즉 굵게를 <b>켜려고</b> 누른 한 번이
선택 전체의 굵게를 껐다. WPF 는 불확정에서 IsThreeState 와 무관하게 false 로 간다.
게다가 세그먼트 토글에는 갈림 표시가 없어 '꺼짐'과 픽셀 단위로 같았다 — 무엇이 일어났는지
볼 수도 없었다. 갈린 상태의 클릭을 '켜기'로 읽고, {x:Null} 표시를 붙였다.
**갈린 값이 '없음'으로 보였다.** 쿼리·마스크·배선 행은 빈 칸이 아니라 "(쿼리 없음)" 같은
<b>단정</b>을 그린다. 서로 다른 쿼리를 든 둘을 골랐을 때 "쿼리 없음"이 나오면 빈 칸보다 나쁘다 —
없다고 믿고 새로 쓰면 양쪽 원본이 한꺼번에 사라진다. "(여러 값)" 으로 바꿨다.
정렬 격자도 갈리면 9칸이 전부 꺼져 '아직 안 고름'과 같았다 — 격자 오른쪽에 표식을 뒀다.
**안 바뀌었는데 문서가 '수정됨'이 됐다.** 갈린 숫자 칸에 "100px" 을 넣으면 스냅샷이 먼저
쌓이고 대상마다 파싱에 실패해 아무것도 안 바뀌었다. 그걸 지우려 누르는 Ctrl+Z 가 다음 문제를 밟는다.
RowBinding.Validate 를 두어 커밋 전에 거르고 칸을 되돌린다. 값이 이미 전부 같으면 스냅샷도 안 찍는다.
**Undo 한 번에 선택이 증발했다.** 문서 교체 후 재구성의 첫 줄이 Selection.Clear() 라,
'여럿 고르기 → 바꾸기 → 확인 → Ctrl+Z → 다시'라는 이 기능의 유일한 작업 흐름이
첫 되돌리기에서 끊겼다. Id 로 다시 찾아 선택을 복원한다.
**텍스트·항목 목록은 확인을 받는다.** 대상마다 다르던 고유값이 한 번에 사라지는 편집이다.
좌표·색·인쇄여부에는 붙이지 않았다 — 확인을 남발하면 정작 위험한 것도 습관적으로 넘긴다.
## 재현하지 못한 것
조사에서 P0로 지목된 ComboBox 코어스 되쓰기(목록에 없는 값을 null 로 바꿔 소스에 되써서
세로선을 회전시킨다)는 <b>재현되지 않았다</b>. 목록 밖 값을 넣은 양성 대조에서도 되쓰기가
관측되지 않아, 이 진단은 그 경로에 둔감하다. 그래서 '되쓰기가 없다'를 주장하지 않는다.
대신 갈렸을 때 빈 문자열 자리를 목록에 만들어(EnsureMixedPlaceholder) 코어스가 성립할
조건 자체를 없앴다 — 덤으로 빈 칸이 "여러 값"으로 보인다.
## 판정
- 단위 시험 388 (BatchEditPolicy 20건 추가)
- edit-smoke 339건 전건 통과 (일괄 편집 12건 · 혼합 표시 4건 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" · --dialog-shots · --cleartype · --db-filecfg 전건 통과
## 남는 것
- 혼합 상태에서 '선택 전체를 빈 값으로' 는 못 한다 — ""→"" 가 커밋되지 않아서다.
하려면 커밋을 우회하는 새 경로가 필요하고 그건 별건이다.
- 갈린 숫자 칸의 ↑/↓ 는 무동작이다(빈칸을 0으로 뭉개지 않으려는 기존 방어).
다중 선택은 좌표가 갈리는 것이 기본에 가까워 자주 보이지만 이번 범위 밖으로 뒀다.
- 라벨의 대문자 Visible 을 PrintFilter 가 레거시와 다르게 읽는 문제는 <b>지금도</b> 있다.
이번 설계는 병합에서 라벨을 빼 새 사례를 만들지 않을 뿐, 기존 어긋남은 고치지 않았다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.
## 미리보기 클릭이 안 먹었다
레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라 눌러 보며 확인한다.
우리 미리보기는 그림이었다 — 그래서 <b>라디오가 배타로 묶였는지 확인할 방법이 없었다</b>.
패널로 묶었다고 믿고 저장했다가 임상 화면에서 두 개가 동시에 켜지는 것이
이 서식에서 가장 흔한 사고인데, 저장 전에 그걸 볼 수단이 없었던 것이다.
PreviewCheckState — 컨트롤 Id → 체크 상태 사전. 문서는 건드리지 않는다.
확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은 것만으로
저장할 내용이 생긴다. 렌더 시점(WithTagValue)에만 덮고, 창을 닫으면 사라진다.
배타는 직계 부모 안에서만. 레거시 RadioButton 은 WinForms 를 그대로 상속해
GroupName 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식 — 패널 1개 = 문항 1개다.
- PreventEditing=True 는 안 눌린다(RadioButton.vb:271-279 — 값 대입만 되고 클릭은 무시).
Locked 는 편집 보호일 뿐이라 눌린다.
- 라디오는 다시 눌러도 안 꺼진다. 끄는 것은 형제를 누르는 것뿐이다.
- 손으로 누른 값이 태그 값을 이긴다 — 그 칸을 직접 눌러 보는 중이므로 사용자의 손이 마지막이다.
- 환자를 바꾸거나 서식을 갈아타면 지운다. 안 지우면 손으로 누른 값이
새 환자의 실제 값을 가려, 확인하려고 만든 창이 확인을 막는다.
- 이 창에서 인쇄하면 화면과 같은 상태로 나간다(같은 checks 를 Print 까지 넘긴다).
히트테스트는 WPF 가 아니라 모델 트리로 한다. 종이 비주얼은 템플릿이 만든 그림이라
컨트롤 하나가 여러 요소로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
캔버스의 HitTestControl 과 두 군데만 다르다 — Locked 를 보지 않고,
그려지지 않은 것(숨김·데이터소스·인쇄 제외)을 뺀다.
## Ctrl+드래그가 복사가 아니었다
예전에는 '선택에 더하고 이동'이었다(Ctrl 이 선택 추가 수정자였으니까).
그래서 같은 컨트롤을 여러 개 놓는 방법이 복사·붙여넣기뿐이었는데,
붙여넣기는 <b>항상 최상위 +12,+12</b> 에 떨어진다.
패널 안 라디오를 그렇게 복사하면 사본이 패널 밖으로 나가 배타 그룹이 깨진다 —
눈으로는 안 보이고 임상 화면에서만 드러나는 고장이다.
DuplicateSelectionInPlace — 제자리 복제. 부모를 유지하므로 사본이 같은 문항 안에 남는다.
드래그 문턱을 넘은 지점에서 복제한다(누름 시점에 하면 Ctrl+클릭만으로도 사본이 생긴다).
복제가 스냅샷을 이미 찍었으므로 이어지는 이동은 추가로 찍지 않는다 —
안 그러면 "복사 후 이동"이 Undo 두 번이 되고, 한 번만 누른 사용자는
원본 자리에 사본이 겹쳐 남은 상태를 본다.
Ctrl 은 이 제스처에서 더하기가 아니다. 합집합으로 두면 아까 선택해 둔 컨트롤까지
함께 복제돼 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다. Shift 는 그대로 더하기다.
선택 단위가 패널이므로 자식을 그냥 집으면 <b>문항이 통째로</b> 복사되고,
더블클릭으로 들어간 뒤 집으면 <b>그 선택지만</b> 복사돼 같은 패널에 남는다.
둘 다 필요한 동작이라 둘 다 시험한다.
## 판정
- 단위 시험 368 (PreviewCheckState 10건 추가)
- edit-smoke 320건 전건 통과 (Ctrl+드래그 복사 9건, 미리보기 클릭 5건 추가)
· 20-5f 의 옛 단언 2건은 의미가 바뀌어 교체했다("합집합으로 함께 움직인다" → "복사된다")
· 20-5c(빈 선택 Ctrl+드래그)가 남기는 사본을 되돌린다 — 안 하면 뒤따르는 개수 검사가 흔들린다
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --db-patient "김" 39건 · --dialog-shots · --cleartype · --db-filecfg 전건 통과
시험을 처음 쓸 때 자식을 Children[0]/[1] 로 집었는데 자식 VM 은 z 순서로 온다.
이미 켜진 라디오를 누르는 시험이 되어 "문서가 바뀌었다"는 거짓 실패가 났다 — Id 로 집는다.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
조사 워크플로(읽기 3 + 반박 검증 3)로 확정한 사실:
- 레거시 RadioButton 은 System.Windows.Forms.RadioButton 을 그대로 상속하고
GroupName 류 속성이 소스·IL 어디에도 없다 → <b>직계 부모가 곧 배타 단위</b>
(검증자가 실제 어셈블리를 로드해 Panel/GroupBox/MExpandablePanel/호스트가
각각 독립 집합이고, 패널 밖으로 옮기면 배타가 즉시 끊김을 실행으로 확인)
- 운영 전수(디자인 1,270건 전량 덤프): 라디오 15,184개 중 <b>15,026개(98.96%)가
패널 직계 자식</b>, 그룹 5,582개(평균 2.72 · 중앙값 2 · 최대 72),
최상위 직속은 158개(1.04%)뿐. 그룹을 나타내는 속성은 0건 —
즉 "패널 1개 = 문항 1개" 가 사실상의 저장 규약이다
그런데 우리는 라디오 Ctrl+G 를 <b>아예 막아</b> 두었다(GroupBlockReason ①) —
운영 서식의 98.96% 패턴을 우리 도구로는 만들 수 없었다는 뜻이다.
막은 이유("일부만 묶으면 택1이 갈라진다")는 타당하지만 그것은 경고할 일이지
기능을 없앨 일이 아니다. 오히려 막으면 한 페이지의 라디오가 전부 한 그룹이 되어
문항을 여러 개 만들 수 없다.
- 차단 제거, 대신 같은 자리에 <b>묶이지 않고 남는 라디오가 있을 때만</b>
"N개 중 M개만 묶습니다 — 남는 것은 다른 선택 묶음이 됩니다" 로 되묻는다
(RadioSplitWarning). 전부 묶으면 되묻지 않는다.
- edit-smoke: "라디오는 묶이지 않는다" 판정을 뒤집어 "라디오도 패널로 묶인다"
+ "패널 자식이 된다" + 경고 판정 2건(일부만/전부)으로 교체
- GroupSelection 의 낡은 XML 주석("동일 GroupId 부여")을 실제 동작(패널 래핑)과
근거 수치로 교체 — GroupId 계열은 이미 죽은 코드다
- dotnet test 358/358 · --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
사용자 결정(②SMB 직접 접근)에 따라 구현. 실측 확인:
- 파일서버 = DB 서버와 같은 호스트, 전송 데몬 포트 2002 는 열림
- <b>SMB 445·139 는 닫힘</b>, UNC·관리공유 접근 모두 실패 → 서버측 조치 필요
- 서명 경로는 서버측 절대경로 D:\MsystechHIS\{사이트}\... (설정 루트 E:\msys\ 와 다름)
그래서 공유가 열리는 즉시 동작하도록 매핑을 넣었다:
Images:SignatureShareMap 에 "서버접두=UNC접두" 를 두면 해석 순서가
①레거시 캐시 ②공유(UNC) ③직접 경로가 된다. 접두는 대소문자 무시로
<b>긴 것부터</b> 맞추고(짧은 접두가 정확한 대응을 묻지 않게), '=' 없는
설정 오타는 무시한다(반쪽 경로를 만들면 조용히 틀린다). 자격증명은
설정에 담지 않는다 — 공유 접근은 단말 로그인 계정으로 붙는다.
진단 --db-filecfg 확장: 접속 <b>대상</b>(Type·IP·Port·FileDirectory)과
경로 접두 분포를 찍고(자격증명은 길이만), 공유 매핑별 접근 가능 여부와
파일서버 SMB(445) 개방 여부를 판정해 무엇을 열어야 하는지 알려 준다.
- dotnet test 358/358 (매핑 4건 추가: 치환·긴 접두 우선·오타 무시·해석 순서)
- --edit-smoke 실패 0 · --db-patient ①~㊲ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
워크플로 조사(읽기 5 + 반박 검증 5)로 전송 계층과 태그 17종을 훑었고,
검증이 조사서의 "FTP" 단정을 반박해 실체가 드러났다: 전송 수단은 단일
프로토콜이 아니라 DB 설정(M_DtlMst 'EMR_FileTransfer' Type)이 고르는
5갈래이고, 새 진단 --db-filecfg 로 확인한 우리 사이트 값은 Type=1 =
FileServer = ActiveX OCX(AxBITSFTLib.AxbitSFT)다. OCX 내부 프로토콜은
코드에 없고 서드파티·ActiveX 는 배포 금지라 <b>다운로드는 재현할 수 없다</b>.
그래서 받지 않고 찾는다: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
({설치경로}\Tmp\DownLoadedFiles\Signature\ — 레거시가 쓰는 그 폴더),
②DB 경로 자체가 열리는 경우(이 병원 저장 형태는 드라이브 절대경로).
둘 다 없으면 사유 → 종이는 빈칸(어제 결정). 판정은 순수 함수
SignatureImagePath 로 두고 테스트 7건(캐시 우선·직접 경로·둘 다 없음·
빈 경로·설치경로 없음·UNC).
경로 조회는 전부 순수 SQL 이라 그대로 이식(SignatureStore): 집도의
(DTRUIDCOD)·마취(수술 ANEUIDCOD / 부서 — 개원일 2021-03-01 분기)·
영상의학(XRAY)·진단검사(LAB)·외출외박 승인자(병동/원무)·병동 수간호사·
병원로고(M_HspMst.HspLgoPth)·병원직인(주석과 달리 DB_REGISTRY 의
'직인경로'). 로그인 사용자 3종은 세션 행에 UidImgPth 를 얹어 왕복 0,
담당의·진단서 의사는 프리페치 행 재사용.
레거시 결함 보존 2건: ETC_병리판독의사싸인은 SELECT 에 UidNam 만 있고
UidImgPth 를 읽어 항상 예외→빈 값(:17434), OCM_Cosign_싸인은 저장 기록
키(EmrKey) 의존이라 미리보기에서 값이 될 수 없다 — 둘 다 사유로 완결.
렌더: PictureBox 가 자리표시자만 그리던 것을 실제 이미지로. 파일을
잠그지 않고(OnLoad) 읽으며, 인쇄가 SizeMode 를 무시하고 사각형에 늘려
그리는 레거시 동작에 맞춰 기본 Stretch=Fill(Zoom 만 비율 유지).
WithTagValue 는 이미지류에서 Text 대신 경로 속성을 채운다.
분류도 정정 — 싸인 계열이 Image(글자만 보여 준다)에서 PatientContext 로.
- dotnet test 354/354 (경로 판정 7건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊲ 전건 통과 — ㊲ 신설: 9개 경로 조회 실행,
마취과 의사 싸인 경로가 실값 29자로 나온다
- --db-filecfg 신설(값 없이 모드만): Type=1 · E_FilInf 102만행 ·
UidImgPth 157행 · 경로 모양 드라이브 절대경로
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
① PDF 높이 어긋남(735pt vs 문서 642pt) 원인 규명·수정: 인쇄가
용지(PrintTicket)를 지정하지 않아 드라이버가 폭이 비슷한 기본 용지
(540×735pt ≈ 16절 190×260mm — test1.pdf MediaBox 실측)에 문서를
얹었다. 폭이 우연히 같아 좌우는 멀쩡하고 아래에만 33mm 여백이 붙어
화면과 다른 종이가 됐다. ShowDialog 전에 PageMediaSize 를 문서 첫
페이지 DIP 크기로 제시 — 사용자가 대화상자에서 용지를 바꾸면 그
선택이 이긴다. (레거시는 사용자가 고른 용지에 문서를 맞추는 방식
(clPrintDesignSheet.vb:660)이라 이 기본값 제시와 충돌하지 않는다.)
② Select 형 DataTableField 필터(운영 268건, 12%) 실행:
지금까지 "아직 옮기지 않았습니다" 사유였다. 필터 문법을 직접 파싱하지
않고 .NET DataTable.Select 에 그대로 넘긴다 — 레거시 배선이 쓰는
바로 그 엔진이라 문법 호환을 증명할 필요가 없다. 행 선택을 순수 함수
(DataTableFieldPicker)로 분리해 DB 없이 검증(테스트 5건 — 필터·0행·
문법 오류·Rows 형·중복 컬럼명 Fill 규칙). 알려진 한계 주석: 우리
결과는 전부 문자열 컬럼이라 숫자 대소 비교 필터는 사전순이 될 수
있다(운영 필터 대부분은 동등 비교).
- dotnet test 347/347 (picker 5건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
지금까지는 값이 안 되는 태그를 [태그명 — 사유] 로 그렸다("못 만든
것을 감추지 않는다"). 실사용 출력물에 그 문구가 찍히면 안 되므로
빈칸으로 바꾼다 — 태그 이름도 지운다(남기면 값이 나온 줄 안다).
렌더 공용 지점(WithTagValue) 한 곳 수정이라 미리보기·인쇄 동시 적용.
체크류(컨트롤 유지)·해석기 없음(디자인 화면 — 태그 이름 유지)은 그대로.
사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
진단이 그것으로 판정한다. 종이 기반이던 사유 판정 8건을 해석기 수준
판정 + "종이에 사유가 새지 않았다" 판정으로 전환:
- 못 만드는 태그/환자 없음/매핑 없음/값 빈 이름 → 해석기 사유 확인
+ 종이 빈칸 확인(각각 쌍)
- 배선 3건(컬럼 오타·없는 소스·형식 깨짐) → 해석기 사유 + 종이 빈칸
- 환자 뗌/세션 뗌 → "[PAT_차트번호] 사유 복귀" 대신 "이름·값 모두 없음"
- dotnet test 342/342 · --edit-smoke 실패 0 · --dialog-shots 통과
- --db-patient ①~㊱ 전건 통과 (⑬ 등은 해석기 직접 판정이라 무영향)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
(렌더 대조는 해석기 없는 경로 — 이 변경의 영향 밖)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_퇴원일시_영문(bzDataInterface.vb:7604 Case Else — 퇴원일시와 같은
분기 구조에 포맷만 MM-DD-YYYY. BSKOREA·YJRCH 등 병원 분기와
KBJY·JEGG MsgBox 분기 미이식), OCM_퇴원일자_낮병동(:7763 — 낮병동
구간의 종료일, 기존 구간 조회 재사용). ETC_수술간호사목록(:15414)은
이름이 "목록"으로 끝나 규칙에 안 걸리는 DataTable 반환 — Table 분류.
이로써 카탈로그 대조 기준 잔여는 싸인·서명·로고·직인 15종(파일서버
이미지 — Image 사유 완결), 콤보용 표 계열(Table 사유 완결),
대조군으로 남긴 ETC_수술실간호사 1종뿐이다. 한 줄 값이 되는 태그는
전부 이식 또는 사유 완결됐다.
- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 259종)
- --db-patient ①~㊱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_재원일수_단입법(bzDataInterface.vb:7947 — 재원일수와 같은 분기
구조인데 +1 이 없다(끝날 미포함). MsgBox 갈래·개원일 분기 처리 동일),
OCM_재원일수_낮병동(:8083 — 낮병동 신청 구간 일수 +1, 입원일자_낮병동
과 같은 조건·결정 규칙), ETC_로그인사용자_병동_수간호사(:15507 —
직급 코드표(PSTCOD)의 '수간호사/간호수선생' 코드로 로그인 부서
재직자를 찾는다. 원문 두 쿼리 모두 정렬 없는 Rows(0) — 코드·사용자
코드 순으로 고정하고 한 왕복으로 접음. 원문의 부서 코드 문자열
연결은 바인드로 정상화).
- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 257종)
- --db-patient ①~㊱ 전건 통과 (낮병동구간·수간호사 실행 확인 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
공통 조회(퇴원약DT, bzDataInterface.vb:10482 — 퇴원의약 오더를
일시·코드·이름 순, 1회 투여량은 SQL 계산) 하나에 파생 6종
(코드/명칭/용량/횟수/일수/용법 — 열 하나를 줄단위로, 마지막 뒤에도
개행, 용법은 LEFT JOIN 미매칭이면 빈 줄) + 고정폭 조립 2종:
OCM_퇴원약(:10617) — PadRight 를 "채울 개수"로 오용한 원문 결함
그대로(실제 의미는 총폭이라 정렬이 어긋난다), 코드가 20바이트를
넘으면 음수 인자 예외 → 사유(레거시는 오류창+빈 값). SATCH 비고
괄호 분기 미이식. OCM_퇴원약_New(:10733) — 바이트 고정폭(코드 10·
명칭 37), 용량·횟수·일수 패딩이 잘라낸 값이 아닌 원본 전체 바이트로
계산되는 결함 보존(5자 이상이면 음수 예외 → 사유).
행이 없으면 SRCH 만 "해당없음." 을 찍는다(:10704/:10864) — 우리
병원 분기라 그대로 값이 된다. CP949 바이트 길이는 ASCII 1·그 외 2
근사(레거시 LenK 대응), 바이트 절단은 문자 경계라 레거시의 반각
깨짐("?") 재절단 분기가 필요 없다(폭 최대 1바이트 차이 — 주석 기록).
- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 250종)
- --db-patient ①~㊱ 전건 통과 (㉟ 에 퇴원약·수술집도의과·수술명칭 실행 확인 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_수술주상병(bzDataInterface.vb:13148 + GetOprInfDT_OKDMain :13364 —
기존 수술 조회와의 실질 차이가 상병 집계의 주상병 한정(OPRVAR1='0')
한 줄이라 SurgeryStore 에 mainDiagnosisOnly 를 얹었다. 치환이
빗나가면 필터 없는 값이 조용히 나가므로 가드로 던진다),
OCM_수술집도의_진료과/_그룹진료과(:3157/:3194 — 집도의(DTRUIDCOD)를
오늘 기준 마스터로 푸는 원문 특이점(OprStt 무필터·SYSDATE 유효기간)
그대로, 최신 수술 첫 행 고정), OCM_수술명칭(:3520 Case Else —
OPRCOD 를 수술일 순으로, 각 행 뒤 공백 1칸 + CRLF 결합. GNBEDRO·
HIMCHAN_BP 의 OPNAME 포함 분기 미이식).
사유 완결 2종: OCM_수술진단명(:3066 — PURME 전용, 그 외 병원은
Case 미매칭으로 항상 빈 값), OCM_수술처치(:3474 — SUSS·YJRCH 전용).
㉘ 정정: 수술 검증이 늘 SKIP 이던 원인은 소유자 탐색을 O_OprInf 로
한 것 — 실제 조회(GetOprInfDT)의 축은 S_OprInf(수술 신청)다.
표를 바로잡자 이 시험 DB 에서 수술 행 1건·OPNAME 실값이 검증됐다
(SurgeryStore 의 낡은 주석도 함께 정정).
- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 242종)
- --db-patient ①~㊱ 전건 통과 — ㉘ 이 처음으로 실값 PASS
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PAT_나이(bzDataInterface.vb:373 Case Else — 통합 대표행의 AgeMonth
파생 컬럼과 같은 식을 SQL 로: UDF_GETAGE_FROMRESNUM(복호화, 적용일시),
dtCommonLib.vb:3725 확인. CGCH 분기·암호화 N 갈래 미이식),
PAT_나이_세(:417 — 산정코드 24* 는 UDF_GETAGE(PatBthDay), 그 밖은
AgeCheck), PAT_성별_나이(:359 — "성별/나이". HIMCHAN_BP·BSYD 분기
미이식), PAT_나이_개월수(:430 — 월 경계 -1, 기존 MonthsSince 재사용),
개월·일 4종(:436-508 — 30일 나눗셈, 새 DaysSince 로 분해).
AgeCheck(clsCommonLib.vb:665-974)를 ResidentNumber.AgeOf 로 이식 —
세기 접두 규칙(1·2→19, 3·4→20, 5·6→출생2자리<20 이면 20xx 아니면
19xx+월일 0101 강제, 7·8→서버 연도 2자리 비교, 그 외→18xx. REDCROSS·
BSYD·BSGH 특례 미이식), 만나이(생일 당일 차감 없음 — 2024-09-25 이후
전 병원 규칙), 1세 이하는 고정 365일. 테스트 5건 — 기대값 하나가
틀려 있었다(출생 2자리 "20"은 <20 이 아니라 1920년으로 강제 — 구현이
맞고 판정을 고침). 개월·일 4종의 기준일은 레거시가 PC 시계(Now)라
서버 오늘로 바꿈(의도적 이탈 — 단말마다 값이 갈리면 안 된다).
PAT_주민번호_Blind/SexTyp_Blind/SexTyp_BlindStar(:105-186 Case Else —
앞6 " - " 뒤 XXXXXXX / 성별1+XXXXXX / 성별1+******. SYBS 의 J-계열
서식 전체 노출 분기 미이식).
대조군 교체: PAT_나이가 이식되면서 ⑬·edit-smoke 의 "안 옮긴 태그"
대조군이 깨졌다(의도된 동작) — ETC_수술실간호사로 교체.
㊱ 신설: 나이 UDF 2종이 실값을 돌려준다(접두 없이 동작 확인).
- dotnet test 342/342 (AgeOf 5건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_알러지_List·OCM_당뇨식칼로리_List 는 이름만 List 로 끝나고 값
한 줄을 돌려준다 — "List 끝 = 표" 규칙보다 먼저 PatientContext 로
잡아 환자 해석기의 실값이 틀린 사유("표를 돌려주는 태그")에 가려지지
않게 했다. 반대로 ETC_전체진료과_한글명/영문명·ETC_의사List_이름순·
ETC_진료과별의사List_이름순 4종은 이름이 규칙에 안 걸리는데
DataTable 을 돌려준다(bzDataInterface.vb:16797-16890) — Table 로
명시(콤보 채움용, 미리보기 한 줄 값이 아니라는 사유가 정확해진다).
GetNurseShtCod_GCRCH/GNBEDRO/SRH 3종은 내부 헬퍼 — NotATag.
참고: ETC_진료과별의사List_이름순은 원문에 :psDepCod 바인드 미등록
결함이 있다(파라미터 사전에 sWrkDte 만 추가 — 실행 시 바인드 불일치).
- dotnet test 338/338 · --edit-smoke 실패 0
- --db-patient ①~㉟ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_영상촬영이력(bzDataInterface.vb:13968) — 영상의학(XRAY) 접수 완료
오더의 일자·이름(30자 절단). 원문 결합 그대로: 한 줄에 2건, 줄 안은
" / "(공백 5), 줄 사이 CRLF, 각 건은 날짜+공백7+이름.
원문 정렬이 8자 절단 별칭이라 같은 날 안 순서가 임의였다 — 원본
일시(OdrDtm)로 정렬해 고정(상위 순서 동일).
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 214종)
- --db-patient ①~㉟ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_적용진료과(bzDataInterface.vb:5659) — 통합 대표행의
AdpDepCod/AdpDtm 은 진료행 CodDepCod 와 적용일시의 별칭임을 확인
(dtHISOperatingInfo.vb:3699 — CodDepCod AdpDepCod, CodDtrCod
AdpDtrCod). 원문이 적용일시 12자리 전체를 8자리 유효기간과 비교하는
특이점 그대로. OCM_적용진료의사(:3939 — AdpDtrCod + 적용일 8자 →
UidNam, 기존 UserNameAt 재사용).
OCM_전과(:12924 + GetCodInfDT With_Join, dtCommonLib.vb:2777) —
진료행 전부(CodMtiSeq='0', 이름 조인은 오늘 기준·DepWrkTyp IN
('D','A'), SYBS·NYHY 의 'B' 추가 미이식)를 과가 바뀔 때만 "과명 /
시작 ~ 끝" 으로 AppendLine. 원문의 DefaultView 필터·정렬은 순회에
반영 안 되지만 SQL 이 같은 조건·순서(ORDER BY CodStrDtm)라 실질
무해였고, 무기한(2999-12-31) 판정은 Substring(1,8) 결함으로 절대
참이 안 되어 실제 값이 찍힌다 — 판정 없이 그대로.
새 사실: GetCodInfDT 는 항상 ORDER BY CodStrDtm(오름차순)이 있다 —
문맥 로드의 "최신순 1행" 결정과 다른 방향이지만 적용일시 구간에
행이 여럿 겹치는 비정상 데이터에서만 갈린다(기존 결정 유지, 기록만).
OCM_간호정보조사지_과거력(:13030 + GetNurseEmrDT :13523 +
GetNurseShtCod :13770) — 서식 우선순위(S113→S202→S163, SRCH 갈래)를
CASE 정렬로 접어 레거시 4왕복을 1왕복으로. 병력 결합 " ,"(공백+콤마)
와 무병력 시에도 붙는 개행까지 원문 그대로. GCRCH·SRH·GNBEDRO 의
서식 코드 분기는 미이식.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 213종)
- --db-patient ①~㉟ 전건 통과 (적용진료과 실값 5자)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
이름은 "부서번호"지만 값은 로그인 사용자 부서의 전화번호다
(bzDataInterface.vb:17287-17313 — M_DepMst.DepTelNum, SYSDATE 기준
M_UidMst 조인). 우리 로그인 조회가 이미 같은 시점·같은 조인으로
부서 행을 붙이고 있어 컬럼 하나(NVL(d.DepTelNum,' '))만 얹었다 —
왕복이 늘지 않는다. HisUser.DepPhone → UserField.DepPhone.
- dotnet test 338/338 · --edit-smoke 실패 0
- --db-patient ①~㉞ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_혈액형(bzDataInterface.vb:11783 — S_BlpInf.BlpAboTyp, 갱신일시
최신 고정), OCM_LMP(:11813 — O_PrgInf.PrgLmpDte, 같은 표를 읽는
임신주기의 갱신일시 최신순으로 고정), OCM_BST/BST_LAST(:12042/:12710 —
바이탈과 다른 표 E_EmdInf_BST, VitalStore 를 표 인자화),
OCM_BMI(:12159 — 차트 전체 최신 체중/키², 반올림 2자리),
OCM_표준체중_LAST(:12234 — (키/100)²×여21·남22),
OCM_조정체중_LAST(:12284 — 표준+(실제-표준)×0.25, 키·체중이 같은 행),
OCM_비만도(:12333 — 이것만 내원 기준, 체중/Round(표준,0)×100).
BMI·표준·조정은 내원(EmrComNum)이 아니라 차트(EmrChtNum) 기준이다 —
주석 처리된 EmrComNum 이 그 흔적. 성별 불명이면 표준·조정은 레거시
그대로 "0" 이 찍히고, 비만도는 0 나눗셈이라 사유로 말한다.
OCM_임신주기는 사용자별 레지스트리 설정(DB_REGISTRY, UidCod 컬럼에
UidNam 을 비교하는 수상한 조건 포함) 의존이라 보류 — 별도 조사 대상.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 164종)
- --db-patient ①~㉞ 전건 통과 (S_BlpInf·O_PrgInf·E_EmdInf_BST 실행 확인)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_협진과(bzDataInterface.vb:6360 Case Else — 회신 완료 CstSttFlg='G'
협진의 회신 과, DISTINCT + 시작일 순 앞 2건을 ", " 결합. GNBEDRO 의
이중 DISTINCT 분기는 미이식), OCM_협진과_협진의(:6437 — M_UidMst 조인
+ 앞 3건을 "과[의사]" 결합), OCM_입원의사(:6499 — 접수 시점 담당의
코드 → 접수일자 기준 UidNam), OCM_퇴원의사(:6583 — 퇴원일시가 비면
바로 빈 값, ILV 면 퇴원 시점·아니면 현재 시점 담당의 → 퇴원일자
기준 이름. 원문의 "퇴원일시 빈 값" If 갈래는 위의 조기 반환 탓에
도달 불가한 데드코드라 옮기지 않았다).
CodInfAt(진료과+담당의 코드 행)·UserNameAt·Consults 를 신설.
이 시험 DB 에 O_CstInf 가 15,224행 있어 ㉞(회신 완료 협진이 있는
내원 → 협진과 실값)를 신설했다 — 2건 결합 확인.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 156종)
- --db-patient ①~㉞ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
진료과 파생 4종(bzDataInterface.vb:5556-6007): 그룹코드("한글명(그룹
코드)" 결합 — 행이 있으면 조각이 비어도 결합), 영어(DepEngNam),
대외명칭(DepOfcNam), 약어명칭(DepBrfNam). 전부 프리페치된 진료과
행(M_DepMst SELECT *)의 다른 컬럼이라 왕복이 늘지 않는다. 레거시의
시점 판정(퇴원→퇴원시점·재원→현재·외래→접수)은 문맥 적용일시와 같은
규칙이다 — 자격이 퇴원보다 먼저 끝난 희귀 케이스만 갈린다(주석).
입원과 2종(:6009-6110)은 접수 시점, 퇴원과 2종(:6162-6318)은
퇴원(ILV)/현재 시점의 진료과 — 문맥과 다른 시점이라
DepartmentCodeAt(GetCodInfDT 대응, 시작일시 최신 1행 고정)과
DischargeDepartment(P_ComInf 에 날짜 조건만으로 M_DepMst 를 조인하는
원문 모양 그대로)를 신설했다. 퇴원과 본체의 If/Else 동일 쿼리(낮병동
주석과 달리 복붙 결함 — 퇴원일시 비면 0행→빈)도 그대로 보존,
한글명칭만 접수일시 폴백이 실제로 있다.
OCM_진료과: 개원일(HspStrDte)이 요양기관 행에 생겨 레거시 분기
(2022-07-01 이후 개원 + 대외명칭 있음 → 대외명칭)를 그대로 복원 —
전에는 개원일이 없어 DepKorNam 으로 고정했었다.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 152종)
- --db-patient ①~㉝ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
OCM_FollowUp(bzDataInterface.vb:1028 — 지금 이후 취소 아닌 내원.
원문의 ROWNUM 은 ORDER BY 보다 먼저 걸려 임의 행이었다 — 인라인뷰
정렬로 "가장 가까운 미래 내원"에 고정), OCM_외출외박신청일/종료일
(:1883/:1936 — 오늘 포함 CowSlpOut='Y' 구간의 시작/끝. 0행이면
서버 현재시각이 나오는 레거시 폴백을 SQL NVL 로 그대로 옮겼다 —
㉛에서 12자 폴백 확인), OCM_최초내원일(:1988 Case Else — 이 쿼리는
원문부터 인라인뷰 정렬이 있어 결정적. KIMEYE 분기 미이식),
OCM_초진일_발병일(:2040)·OCM_신환_초진일(:2138 — 진료과 앞 2자를
레거시는 GetCodInfDT 임의 행에서 뽑았지만 우리는 문맥의 진료 행에서).
"지금/오늘"은 전부 서버 시각(SYSDATE) — 레거시 SystemDateTime 과
같은 의미이고 단말 시계에 좌우되지 않는다.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 131종)
- --db-patient ①~㉝ 전건 통과 — ㉝ 신설: 재진 있는 차트로 실값 검증
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
PAT_실제생년월일(bzDataInterface.vb:226 — P_PatEtcInf.PatEtcBirDte,
8자리·연월일 조각이 0 이 아닐 때만, YYYYMMDD 원문 그대로),
PAT_국적(:639 — M_DtlMst NATCOD), PAT_장애등급(:687 — P_pdsoInf
PdsoFlg='E', PdsoGrd 두 번째 글자), PAT_건보세대주명(:732 — P_PISINF
⨝P_CoiInf, PisInsCod='1'), PAT_건보_급여_세대주명(:780 — 자격
(코드·순번) 직접 지정; _Refer 갈래는 미리보기 미설정이라 자기 내원만),
PAT_협력업체(:837 — ComCoopHsp→M_DtlMst, 원문대로 DtlTblCod 없음),
PAT_보호자연락처(:881 — PatEtcGrdnPhn IS NOT NULL).
새 PatientExtraStore 에 자기 SQL 을 모았다. 차트번호는 문맥이 Trim
하므로 CHAR 컬럼은 RPAD(:c,10) 복원 매칭 — ㉜ 가 실값으로 증명한다.
P_pdsoInf 만 VARCHAR2 라 IN (:c, RPAD(:c,10)) 양쪽을 본다
(㉛ [조사]: 이 DB 는 패딩 행 0 — 운영 DB 가 다를 수 있어 유지).
ORDER BY 없는 Rows(0)/RowNum 1 은 전부 정렬 명시로 고정(갱신일시·
시작일 최신) — 장애등급의 P_ComInf 조인은 행만 곱해서 EXISTS 로 대체.
해석기는 지연 + 태그별 캐시(이 태그 없는 서식이 대부분).
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 125종)
- --db-patient ①~㉜ 전건 통과 — ㉛ 7종 실행, ㉜ 국적 실값 검증 신설
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Computed 6종: OCM_입통원구분_입원/통원(Boolean→"True"/"False",
bzDataInterface.vb:989/1009, PatTyp=ComPatTyp 는 bzPatientInfo.vb:639),
OCM_외래내원일시(:1068), OCM_외래내원일자_영문(:1091 —
Data2Format_ENG "MM-DD-YYYY" 는 월 영문화가 아니라 자리 재배열,
clsCommonLib.vb:8591), OCM_입원내원일시/일자(:2824/:2840 Case Else —
KIMEYE 만 ComTrsDtm, 미이식 주석).
FromResidentNumber 2종: PAT_성별_남/여(:317/:338) — F/M→N·Y 변환,
그 외 값은 레거시가 Select Case Else 없이 그대로 돌려주므로 그대로.
WithTagValue 에 체크류 갈래 신설: CheckBox/RadioButton 에 태그가
걸리면 Text 치환이 아니라 Checked 를 바꾼다 — 레거시 SetValue 가
"Y"/"TRUE"(대소문자 무시)만 체크로 바꾼다(CheckBox.vb:688-696 ·
RadioButton.vb:338-351). 값을 Text 에 넣으면 라벨이 "True" 가 된다.
미리보기·인쇄가 같은 BuildPageVisual 경로라 한 곳 수정으로 둘 다 반영.
PAT_성별_나이는 AgeCheck 의존이라 나이 계열과 함께 보류.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 118종)
- --db-patient ①~㉚ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ETC_병리판독의사명/전문의번호(bzDataInterface.vb:17318/:17515 — M_UidMst
UidDtrYon='Y' AND UidDepCod='TLAB' + 유효기간), ETC_진단검사의사명/
전문의번호(:17550/:17655 — M_DepMst DepGrpCod='LAB' 조인 + UidLicNum
IS NOT NULL). 환자와 무관한 원내 의사 조회지만 레거시가 환자 태그로
노출하므로 PatientTagResolver 에 둔다(lazy 소스 + 캐시 — 이 태그가
없는 서식이 대부분이라 프리페치하지 않는다).
의도적 이탈 하나: 원문엔 ORDER BY 가 없어 TLAB 의사가 둘이면
이름과 전문의번호가 서로 다른 사람이 될 수 있었다(태그마다 따로
조회하므로). ORDER BY UidCod + 1행 고정으로 두 태그가 반드시 같은
사람을 가리키게 했다 — 담당의 때 정한 결정 규칙의 세 번째 적용.
- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 110종)
- --db-patient ①~㉚ 전건 통과 — ㉚ 신설: 두 조회가 예외 없이 돈다
(이 시험 DB 는 TLAB·LAB 의사 0행 — 태그는 사유로 완결되는 정상 경로)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
S_OprInf(수술 접수 일정 — GetOprInfDT 의 E_OprInf 축 큰 조회와 다른 표) 를
OprComNum + OprStt='E' 로 거르고 OprKey 순으로 한 행:
- ETC_수술일자_1_몇년/_1_몇년2자리/_2_몇월/_3_몇일 — DESC(마지막 수술, '수술실 요청' 주석 그대로), OprDte 자름
- OCM_수술일자_마지막수술 — DESC, "9999-99-99" 형식
- OCM_수술일자 — Else 갈래는 ASC(첫 수술). PURME(주사오더 O_OdrInf 조회)·
BSGH·HIMCHAN_*·GJHNSS(DESC) 분기는 미이식 안내 — Else 로 뭉개면
그 병원에서 첫/마지막이 뒤바뀐 날짜가 조용히 찍힌다.
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 106종)
- --db-patient 전건 통과 · --db-render P062 md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 세션 조회 확장(UserContextStore)
M_UidMst 행에서 UidLicNum·UidSpcLic 를 더 읽고, 직종명은 레거시 그대로
DtsDtlCod='JOBCOD' 만으로 M_DtsMst 를 조인해(bzDataInterface.vb:17944 —
DtsTblCod 조건이 없다) DtsCodNam 을 가져온다. HisUser 에 세 필드 추가.
## 태그 5종 + Dead 1종
- ETC_로그인_의사면허번호(UidLicNum) · 전문의번호(UidSpcLic) · 직종(DtsCodNam)
- ETC_로그인_근무부서 = 세션 부서 한글명. 레거시는 개원일(HspStrDte>=20220701)로
DepOfcNam 을 가르지만 개원일 미상 결정에 따라 DepKorNam 갈래다(기존 결정과 동일).
- ETC_로그인_근무부서_사용자명 = "부서-이름". 레거시는 UidNam 으로 걸러
동명이인에서 남의 부서가 나올 수 있는데 세션 사용자 행이라 결정적이다(주석 기록).
- ETC_로그인_직급 → Dead. 값은 UidNam 을 담고 조건은 UidCod 로 걸어(:17262·17272)
실무상 늘 빈칸이던 태그다. 고치면 없던 값이 갑자기 채워지므로 합의 전까지 레거시 유지.
레거시의 면허·전문의번호 조회는 유효기간 없이 M_DepMst 를 불필요하게 조인해
ORDER BY 없이 첫 행을 집는 비결정 조회였다(조사 문서 risk) — 세션 행(유효기간 검증
완료)에서 읽는 쪽이 결정적이고 값 원천(M_UidMst 같은 행)은 같다.
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 100종)
- --db-patient 전건 통과 · --db-render P062 md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 바이탈(측정치) 17종
E_EmdInf_VITAL ⨝ E_EmrInf(삭제 제외) 를 내원으로 거르고 해당 컬럼이 빈 값이 아닌
행을 측정일시 순으로 한 건 집는다 — 무인자는 첫 측정(ASC), _LAST 는 마지막(DESC).
키/몸무게/체온/맥박/호흡/SPO2 (±LAST) · 혈압/혈압_LAST(BPS||'/'||BPD) ·
혈압_BPS/_BPD(단독값이지만 널 필터는 둘 다 — 레거시 그대로) · VITAL접수일시_LAST(HH:MM).
태그별 (식·컬럼·필터·정렬)을 전부 본문에서 확인해 표로 박았다(:11508-13046).
조회는 태그가 물을 때 한 값씩, 태그별 1회 캐시.
## 머리둘레 2종은 레거시가 고장이다
SELECT 는 EmdHc AS HC 인데 Item("BP") 를 읽는다(:11744, 12581) —
행이 있으면 예외 → MessageBox → "". 레거시에서 한 번도 값이 나온 적 없는 태그다.
그대로 빈 값 + "레거시 결함(컬럼명 불일치)으로 항상 빈 값이던 태그" 사유로 둔다.
고쳐서 값을 내면 레거시와 달라진다 — 고칠지는 별도 결정.
## 실DB 확인
--db-patient ㉙ 신규: 내원 6004487 에서 첫/마지막 몸무게 2자.
표 부재 가능성도 수술과 같은 방식으로 가른다(ORA-00942 명시).
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 95종)
- --db-patient ①~㉙ 전건 통과 · --db-render P062 md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## OCM_STFDTR
담당의 조회를 코드 컬럼만 일반화했다(CodDtrCod → CodStfDtr 파라미터).
시각 갈래는 담당의와 같다(bzDataInterface.vb:4026-4067).
코드가 비어 있으면 레거시도 즉시 "" — 진료과 행이 돌아오면 주치의 행이 아니므로 비운다.
## _Refer 계열은 "안 옮긴 것"이 아니었다
참조 내원은 EMR 실행 화면(ActionTag — fmCodLstActionTag.vb:54 등)에서
<b>사용자가 지정하는 값</b>이고, 레거시 서식생성기 미리보기(TestPatientSetting)는
그것을 설정하지 않는다. 즉 <b>레거시 미리보기에서도 _Refer 태그는 전부 빈 값</b>이다.
지금까지 그 태그들이 "환자 태그이지만 아직 옮기지 않았습니다"로 나왔는데 그건 틀린 사유다 —
옮길 것이 없는 게 아니라, 이 화면에는 참조 내원이라는 개념 자체가 없다.
사유를 갈랐다: "참조 내원 태그입니다 — 참조 내원은 EMR 실행 화면에서 지정되며,
레거시 미리보기에서도 빈 값입니다". _Refer ~40종의 표시가 이것으로 바뀐다.
이로써 <b>서식생성기 미리보기 기준의 레거시 호환</b>에서 _Refer 계열은 완결이다 —
레거시가 빈 값인 자리는 빈 값(+정확한 사유)이 맞는 이식이다.
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 76종)
- --db-patient 전건 통과 · --db-render P062 md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Bkd_상병쿼리(bzDataInterface.vb:14625-14811)의 이식. DiagnosisStore 와 구조가 같고
표만 B_OkdInf(심사분)다. SQL 52줄 원문 기계 추출(블록1 UNION 블록2, 주/부 필터는
각 블록의 RN 접기 앞). 병원 분기는 KIMEYE 하나 — 그 병원만 미이식 안내.
태그 4종(본문 확인): 심사_주상병명/코드는 첫 행, 심사_부상병명/코드는 줄바꿈 결합
(가로형이 없다).
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 63종)
- --db-patient 전건 통과 (B_OkdInf 도 이 시험 DB 에 없을 수 있어 수술과 같은 취급 —
런타임에서 해석기가 예외를 사유로 바꾼다)
- --db-render P062 md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 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>
## 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>
## conflict 6건 (통합 계획의 "고칠 것")
값이 바뀌는 것 둘:
- ETC_현재년도 — "2026" → "2026년"(bzDataInterface.vb:16923 의 + "년").
형제 ETC_현재일시_1_년 은 접미가 없어 둘을 갈랐다(ClockFormat.YearSuffixed 신설).
"2026" 을 단정하던 테스트도 고쳤다.
- ETC_로그인_사용자ID_사용자명 — 레거시(:17930)는 빈 값 검사 없이 항상 결합한다.
한쪽이 비어도 "U01." 이 그대로 나간다. 조건을 붙였던 것이 레거시와 달랐다.
행 선택이 바뀌는 것 둘:
- ETC_로그인_사용자연락처 / 원내연락처 — 전에는 M_UidMst 를 조건 없이 첫 행.
이력이 여러 행인 사용자에서 옛 연락처가 나올 수 있었다.
유효기간(SYSDATE 일자 BETWEEN)으로 고르고, 0행이면 레거시 폴백(:10641)처럼
조건 없이 다시 찾는다.
의도적 차이로 기록한 것 둘(코드 주석):
- 유효기간 판정 일자 — 레거시는 로그인 시점, 여기는 SYSDATE(자정 넘긴 세션에서만 갈림).
- Trim — 레거시는 무가공, 여기는 걷는다(CHAR 잔여 공백이 종이에 찍히는 쪽이 더 이상하다).
## new 48종 중 재료 재사용으로 되는 10종
워크플로 매핑 상세(docs/new48-mappings.json)를 붙여 보니 46종이 needs-lookup 인데,
그중 10종은 <b>이미 든 행의 다른 컬럼</b>이거나 문맥 계산이었다:
- 담당의 행 재사용 3: OCM_담당의사_영문(UidEngNam) · 면허번호(UidLicNum) · 전문의번호(UidSpcLic)
— 조회 갈래가 OCM_담당의사와 문자 그대로 같고 집는 컬럼만 다르다(:4094·4119·4144)
- CowInf 재사용 3: OCM_병실베드(CowBedCod) · 병동_병실 · 병동_병실_베드(" / " 결합, 행 없으면 "")
- 문맥 계산 1: OCM_내원유형(입원/응급실/외래 — ComPatTyp·ComEmgYon)
- User 계열 3: OCM_User진료과 · _한글명칭 · User진료의사명.
로그인 사용자가 의사(UidDtrYon=Y)면 사용자의 과·이름, 아니면 환자의 것.
HisUser 에 DoctorYn·DepOfficialName 을 추가했다(세션 조회 SELECT 확장).
남은 new 는 새 조회가 필요한 세 무리다: 상병(Okd_상병쿼리) · 수술(GetOprInfDT) ·
심사(Bkd_상병쿼리). 다음 배치.
## 게이트
- dotnet test 338/338 (현재년도·ID.사용자명 판정 갱신)
- --edit-smoke 실패 0 (이름 검사 40종)
- --db-patient ①~㉔ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자가 서식에서 빈칸으로 확인한 자리들이다(B·C 배치).
## 본문에서 확인한 것
- OCM_병동 / OCM_병실 (bzDataInterface.vb:6718, 6801)
문맥의 CowInf 행에서 <b>코드값 그대로</b>다(CowWadCod·CowRomCod).
마스터로 이름을 찾지 않는다 — 레거시가 코드를 그대로 찍는다.
외래는 CowInf 가 0행이라 자연히 빈 값(레거시도 외래·비낮병동은 "").
- OCM_진료과 (:5437-5495)
M_DepMst 를 CodDepCod + 접수일자 BETWEEN 으로 찾는다.
유효기간 일자는 입원·외래 무관하게 <b>접수일자</b>다(:5445).
이름 선택: NH 는 DepOfcNam, 그 밖은 개원일 2022-07-01 이후 + DepOfcNam 존재 시
DepOfcNam, 아니면 DepKorNam. 개원일은 세션에 없어 빈 값 결정(기존과 동일)이라
그 갈래는 DepKorNam 으로 간다.
- ETC_요양기관명칭_병원명 (:15792-15860)
M_DepMst ⨝ M_HspMst(DepHspCod=HspCod) 에서 HspInsNam.Trim.
진료과 코드로 병원을 찾는 구조다(다부지 구성).
판정 일자가 진료과와 다르다 — ILV 는 퇴원일시·재원 중은 지금·외래는 접수일시.
문맥의 적용일시가 정확히 그 판정이라 AdpDtm 을 그대로 쓴다.
원본 마스터 조회에 ORDER BY 가 없어 담당의 때와 같은 규칙(최신순 1행)으로 고정했다.
## 왕복은 환자당 두 번 추가
진료과·요양기관명을 환자를 붙일 때 한 번씩 읽는다(주민번호·주소·담당의와 같은 정책).
미리보기 창과 Ctrl+P 인쇄(PatientSession.ResolversFor) 둘 다 같은 재료를 받는다.
## 실DB 확인
--db-patient ㉓·㉔ 신규: 진료과 코드 '0506' → 44열·한글명 5자, 요양기관명 6자.
코드가 이름이 되는 것까지 확인해야 태그가 값을 낸다.
## 게이트
- dotnet test 338/338 · --edit-smoke 실패 0(이름 검사 30종)
- --db-patient ①~㉔ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자가 미리보기 화면과 실제 인쇄 PDF 를 나란히 놓고서야 드러난 결함.
미리보기에는 환자 값(등록번호·성명·생년월일)이 찍히는데
같은 창의 '인쇄' 버튼으로 뽑은 종이는 태그 이름 그대로였다.
## 원인
BuildFixedDocument 가 BuildPageVisual(page) 을 인자 없이 불렀다 —
기본값이 tags=null, fields=null 이라 인쇄 경로에서만 해석기가 사라졌다.
화면에서 확인하고 인쇄하면 다른 종이가 나온다. 미리보기의 존재 이유를 무너뜨리는 결함이다.
## 고친 것
- Print/BuildFixedDocument 가 해석기 한 벌을 받아 끝까지 넘긴다.
- 미리보기 창의 인쇄는 그 창이 화면에 그리는 것과 <b>같은 해석기</b>를 넘긴다.
- 미리보기를 안 거친 인쇄(Ctrl+P)도 세션 환자를 따른다 —
PatientSession.ResolversFor(document) 가 한 벌을 만들어 준다
(주민번호·주소·담당의를 그 자리에서 한 번씩 읽는다).
- printFilter 는 인쇄에서 고정 true 다. 레거시 인쇄가 PrintOutPut=False 를 빼므로
미리보기의 보기 토글과 무관하다.
## 게이트를 추가했다
이 판정이 없어서 지금까지 못 잡았다:
인쇄(대조군): 해석기가 없으면 값이 없다
인쇄: 해석기를 넘기면 환자 값이 인쇄 종이에 찍힌다
인쇄: 미리보기 종이와 인쇄 종이의 글자가 <b>같다</b> (문자열 완전 일치)
대조군이 먼저다 — 없으면 "값이 어디서든 나온다"와 구분되지 않는다.
## 남긴 것
PDF MediaBox 높이가 856 DIP(642pt)가 아니라 735pt 로 나오는 문제는 별도다 —
용지·여백 처리 조사가 필요하고 이 수정과 무관하게 재현된다.
## 게이트
- dotnet test 338/338
- --edit-smoke 실패 0 (인쇄 판정 3건 신규)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
검증 통과 태그를 SheetMe 현행 구현과 대조했다(에이전트 6, 73만 토큰).
new 48 / already 13 / conflict 6 / skip 1
통합하면 26종 → 74종이 되고, 그와 별개로 6건을 고쳐야 한다.
대조를 안 했으면 already 13종을 두 곳에서 처리하게 됐을 것이다.
## 지난 커밋의 내 주장이 틀렸다
WORKFLOW-CTX-ROUND1.md 에 ETC_로그인_사용자명 이 NVL(UidNam,' ') 때문에
레거시와 갈린다고 적었다. 틀렸다 — UserContextStore.cs:80 의 .Trim() 이
그 공백을 도로 걷어내서 NULL 케이스는 양쪽 다 "" 다.
검증이 코드를 직접 읽고 반박했다. 남는 실차이는 다른 것이다(SheetMe 만 Trim).
## 네 번째 차이를 찾았다
ETC_현재년도 — 레거시는 "2026년"(접미 "년"), SheetMe 는 "2026".
앞선 문서의 "차이 3건"에 없던 것이다.
고칠 때 TagPreviewCatalogTests.cs:108 이 "2026" 을 단정하므로
그 판정도 함께 바뀌어야 한다 — 검증이 짚어 줬다.
## conflict 6건
사용자ID_사용자명(결합 조건) · 사용자ID(기준일·Trim) · 사용자명(Trim) ·
사용자연락처(유효기간 없이 첫 행) · 원내연락처(같은 조회 공유) · 현재년도(접미사).
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
오케스트레이션 에이전트 14개, 183만 토큰, 도구 413회. ok 210 / wrong 21.
## 결론
검증이 붙인 위험 신호 빈도가 이 조사의 답이다.
ORDER BY 없이 Rows(0) 79
결과가 비결정적 74
예외 경로 문제 76
문자열 결합(바인드 아님) 47
SQL 주입 구멍 28
조회 함수가 MessageBox 를 띄움 42
담당의에서 겪은 <b>정렬 없는 Rows(0)</b> 이 79곳에 더 있다.
그때 세어 보니 25,488건이 걸렸다 — 종당 같은 조사가 필요하다는 뜻이다.
## 검증이 잡은 레거시 결함(표본)
- ETC_로그인_전문의번호_Refer 는 컬럼명에 후행 공백이 있어 행이 있을 때 예외가 난다.
- ETC_로그인_직급 은 값은 UidNam 을 담고 조건은 UidCod 로 건다 —
실무상 0건이라 늘 빈칸이었을 것이고, 고치면 없던 값이 갑자기 채워진다.
- ETC_병리판독의사싸인 은 SELECT UidNam 한 컬럼만 뽑고 Item("UidImgPth") 를 읽는다.
## 검증이 추출을 반박한 예
ETC_마취및소독간호사List 에서 추출은 "0건이면 NullReferenceException"이라 했다.
검증이 clsOracleDA.vb:789 를 직접 읽고 <b>빈 DataTable 이 반환된다</b>고 반박했다 —
실제 동작은 예외가 아니라 구분선 한 줄만 든 목록이다. 이식 시 분기가 완전히 달라진다.
혼자 읽었으면 그대로 믿었을 것이다. 반박을 임무로 준 것이 잡았다.
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
배치 2 재실행 완료. 에이전트 12개 전부 성공, 오류 0.
ok 118 / wrong 6 / unsure 2, 고유 태그 105종.
## 가장 값어치 있는 것은 "옮길 것"이 아니었다
검증이 <b>이미 들어가 있는 SheetMe 코드</b>의 차이를 셋 잡았다.
1. ETC_로그인_사용자ID_사용자명 — 레거시는 항상 UidCod + "." + UidNam 을 만드는데
TagPreviewCatalog.cs:197 은 둘 다 비어 있지 않을 때만 만든다.
2. ETC_로그인_사용자명 — UserContextStore.cs:62 가 NVL(UidNam,' ') 로 공백을 채워
UidNam 이 NULL 인 사용자에서 레거시("")와 값이 갈린다.
3. 로그인 사용자 유효기간 기준일 — 레거시는 GetAdpDtm 앞 8자, SheetMe 는 SYSDATE.
셋 다 태그를 새로 옮기다 나온 게 아니라 <b>기존 동작이 이미 달랐던</b> 것이다.
## 되살릴 때 걸리는 것
ETC_건강보험증번호_Refer 는 죽은 코드지만 주석 속 원래 구현이 ComInfDR_Refer
(참조 내원행)를 읽는다 — 다섯 문맥 표의 ComInf 로 그냥 치환하면 안 된다.
## 통합 전 대조가 먼저다
이번 배치는 대부분 ETC_ 계열이라 환자 태그가 아니고,
상당수를 SessionTagResolver/TagPreviewCatalog 가 이미 처리한다.
그대로 넣으면 같은 태그를 두 곳에서 처리하게 된다.
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
오케스트레이션으로 레거시 본문을 병렬로 읽고 각 매핑을 다른 에이전트가
<b>반박하도록</b> 시킨 결과. 에이전트 11개, 103만 토큰, 도구 299회.
내 컨텍스트는 거의 쓰지 않았다 — 오케스트레이션을 쓴 이유가 그것이다.
ok 94 / wrong 10 / unsure 1, 고유 태그 84종
검증을 "통과시키는" 임무로 줬다면 wrong 10건이 그대로 코드에 들어갔을 것이다.
이 세션에서 당한 사고가 전부 "확인 없이 넣은 것"이었다.
## 이번 배치는 환자 태그가 아니다
대부분 ETC_ 계열(시각·로그인 사용자)이고, 상당수는 SessionTagResolver 가
이미 처리하고 있을 수 있다. 통합 전에 중복을 걸러야 한다 — 그대로 넣으면
같은 태그를 두 곳에서 처리하게 된다.
## 건진 것
- ETC_건강보험증번호_Refer 는 죽은 코드다(본문 전체가 주석, Return "" 하나).
- ETC_현재일시_MaskBox 는 마스크 때문에 일과 시가 붙는다(2026-08-1914:30).
- ETC_로그인_원내연락처 의 컬럼은 UIDOFFTEL 대문자다(형제는 파스칼).
- 검증이 기존 SheetMe 코드의 차이도 잡았다 — 레거시는 GetAdpDtm 앞 8자,
SheetMe 는 SYSDATE 로 사용자 유효기간을 본다.
배치 6개 중 1개는 API 521 로 미실행이라 재실행이 필요하다.
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CTX 126종 배치의 시작. 본문을 정확히 읽은 둘만 넣는다 —
나머지 세 변형(_00Months_00Days · _개월_00일 · _00개월_일)은 형식이 다를 수 있어
읽기 전에는 넣지 않는다.
## 레거시가 이상해 보이지만 그대로 옮긴 것 둘
**PAT_나이_개월수** = `DateDiff(Month, PatBthDay, SystemDate) - 1`
VB 의 DateDiff(Month, ...) 는 <b>날짜를 보지 않고 월 경계만</b> 센다(연차×12 + 월차).
그래서 1일생과 말일생이 같은 값이 나오고, 같은 달이면 -1 이 찍힌다.
일수로 환산하면 레거시와 다른 수가 된다 — 판정으로 못 박았다.
**PAT_나이_00개월_00일** = `days \ 30 & "개월 " & days Mod 30 & "일"`
달력이 아니라 <b>30일로 나눈다</b>. 365일이 "12개월 5일"이 된다.
달력으로 고치면 값이 달라진다.
## 내 산수가 틀렸다
기대값을 처음에 11로 적었다. 2025-01 → 2025-12 는 월 경계 11 이고
거기서 1을 빼면 10 이다. 구현이 아니라 판정이 틀렸고, 테스트가 그것을 잡았다.
## 아직 배선하지 않았다
두 함수는 "오늘"이 필요한데 해석기에 그 값을 넣는 배선(서버 시각 1회 읽기)이 남았다.
컨텍스트가 부족해 배선까지 하면 게이트를 못 돌리므로, 검증된 순수 함수만 먼저 넣는다.
다음 세션 첫 작업이 배선이다.
## 게이트
- dotnet test 338/338 (나이 판정 2건 신규)
- --edit-smoke 실패 0
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## 정정
전에 "MessageBox 를 띄우는 48종은 대화형이라 미리보기에서 원리상 실행할 수 없다"고
문서와 답변에 적었다. <b>틀렸다.</b> MessageBox 는 전부 Catch ex As Exception 안에 있다 —
오류 경로일 뿐이고, 우리는 예외 시 대화상자 대신 사유를 돌려주면 된다.
앞선 분류가 그렇게 나온 이유는 내 awk 가 MessageBox 를 SELECT 보다 먼저 봤기 때문이다.
분류 순서 하나로 "못 옮긴다"는 결론이 나왔고, 그것을 사용자에게 두 번 말했다.
즉 <b>옮길 수 없는 태그는 없다.</b> "완벽한 레거시 호환"은 달성 가능하고 남은 것은 물량이다.
## 실제 분포 (남은 360종)
CTX 126 문맥·계산만 — 조회 없이 옮길 수 있다
SQL 231 본문에 자기 SQL — 읽고 바인드로 옮기고 실DB 로 확인
HSP 3 병원 코드로 갈린다
태그별 줄 번호까지 색인에 넣었다 — 다음 작업이 레거시를 다시 파헤치지 않고
해당 줄로 바로 가서 구현만 하면 된다. CTX 126종이 다음 목표다.
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23종 → 26종. 조회 둘(P_CodInf 선택 + M_UidMst)이 새로 붙었다.
## 원칙을 처음 깬다
지금까지는 "레거시가 이상해 보여도 그대로 옮긴다"였다
(주민번호의 DateValidCheck 가 그랬다 — 조건이 항상 참이지만 그대로 뒀다).
여기서는 그럴 수 없다. GetCodInfDT 는 ORDER BY 없이 CodMtiSeq='0' 만 걸고
호출부가 Rows(0) 을 집는데, 이 DB 에서 그런 내원이 <b>25,488건</b>이고 최대 41행이다.
41행 중 첫 행은 사실상 무작위다 — <b>재현할 결정적 동작이 존재하지 않는다.</b>
그래서 적용일시로 거르고 최신순 1행을 집는다(문맥의 CodInf 와 같은 규칙).
레거시도 이 시각을 넘기기는 한다 — 마스터 유효기간 조인에만 쓸 뿐이고
P_CodInf 의 CodStrDtm/CodEndDtm 은 WHERE 에 없다.
"그 시점의 진료과"를 뽑으려던 의도가 반쯤만 구현된 것으로 보인다.
근거는 docs/TAG-PORTABLE-48.md 에 남겼다.
## 폴백을 빠뜨리지 않았다
GetUidMst(clsCommonLib.vb:10615)는 유효기간으로 먼저 찾고
<b>0행이면 코드만으로 다시 찾는다</b>. 그 폴백을 빼면 유효기간이 지난 의사가
통째로 빈칸이 된다. 두 번째 조회까지 옮겼다.
## 세 갈래 시각
입원 · ComPrgStt='ILV' → 퇴원일시
입원 · 그 밖 → 문맥의 적용일시
외래 → 접수일시
담당의 유효기간을 볼 날짜도 갈린다(입원은 진료과 행의 CodEndDtm, 외래는 접수일자).
## 또 같은 함정을 밟을 뻔했다
㉒ 를 처음 돌렸을 때 0열이 나왔다. 쿼리 문제로 보였지만 아니었다 —
시험 내원(1988년)에 P_CodInf 행이 아예 없었다. 앞서 "내원 0건인 환자로 판정하려 한" 것과
같은 함정이다. 행을 가진 내원을 찾아 걸도록 고쳤다: 내원 5000323 → 52열, 이름 3자.
## 게이트
- dotnet test 336/336
- --edit-smoke 실패 0 — 이름 검사가 26종 전부 실제 태그임을 확인
- --db-patient ①~㉒ 전건 통과 (㉒ 담당의 조회 신규)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
CodMtiSeq=0 행이 2개 이상인 내원: 25,488건, 최대 41행.
0건이면 없는 문제였겠지만 아니었다.
41행짜리 내원에서 ORDER BY 없는 Rows(0) 은 사실상 무작위다.
레거시는 지금도 그 25,488건의 담당의를 확정적으로 찍지 못하고 있다 —
재현할 대상 자체가 없으므로 "그대로 옮긴다"가 성립하지 않는다.
정황이 하나 더 있다. 레거시는 GetCodInfDT 에 적용일시를 넘기면서도
그것으로 P_CodInf 를 거르지 않는다(넘긴 날짜는 마스터 유효기간 조인에만 쓴다).
P_CodInf 에 CodStrDtm/CodEndDtm 이 있는데 WHERE 에 없다 —
"그 시점의 진료과"를 뽑으려던 의도가 반쯤만 구현된 것으로 보인다.
측정 코드는 진단에 남겼다. 세기 전에는 A 와 B 중 어느 쪽인지 알 수 없었다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
조회 두 개(GetCodInfDT With_Join · GetUidMst)와 세 갈래 호출 조건을
문서에 그대로 옮겼다 — 다음 세션이 다시 파헤치지 않아도 된다.
## 그 과정에서 걸린 것 둘
① 조회에 ORDER BY 가 없는데 호출부가 Rows(0) 을 집는다.
한 내원에 진료과 이력이 여럿이면 <b>오라클이 어느 행을 먼저 주느냐에 따라
담당의가 달라진다</b>. 레거시 동작 자체가 비결정적이라
"그대로 옮긴다"가 성립하지 않는다 — 정할 문제다.
② 위 SQL 이 이미 담당의 이름(CodDtrCod_Nam)을 돌려주는데
태그는 그것을 안 쓰고 다른 날짜로 GetUidMst 를 한 번 더 친다.
날짜가 다르니 두 값이 갈릴 수 있다. 왕복을 줄이려고 첫 결과를 쓰면
값이 달라진다 — 레거시를 따르려면 두 번 쳐야 한다.
그리고 이 조회는 PatientContextStore 가 읽는 CodInf 와 선택 규칙이 다르다
(그쪽은 BETWEEN + 최신순 1행). 문맥의 CodInf 를 재사용하면 안 된다.
코드 변경 없음(문서만). 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
남은 363종의 성격이 서로 달라 한 번에 갈 수 없다.
232종은 각각 자기 SQL 을 든 별도 이식이고,
48종은 MessageBox 를 띄워 미리보기에서 원리상 실행할 수 없다.
다음 배치를 순서대로 적어 뒀다. OCM_담당의사 계열이 먼저인 이유는
그것이 요구하는 조회 둘(GetCodInfDT·GetUidMst)을 만들고 나면
뒤따르는 여러 태그가 같은 것을 재사용하기 때문이다.
배치마다 지킬 것도 함께 적었다 — 근거 줄번호, 이름 검사,
짐작 금지, 값보다 사유.
문서만 변경. 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
19종 → 23종. M_ZipMst 를 건물관리번호(PatZipBdg)로 찾는 조회 하나가 추가됐다.
## 결합식을 그대로 옮긴다
레거시(clsCommonLib.vb:16600)는 DECODE 로 빈 조각을 건너뛰며 구분자를 붙인다.
시도·시군구·읍면·도로명·지하여부·건물번호 본번/부번·건물명이 각각 다른 규칙으로 이어진다.
손으로 다시 짜면 주소가 미묘하게 달라지므로 SQL 을 그대로 가져왔다(바인드는 유지).
## 눈으로 보면 오타 같은 것 셋
PAT_도로명주소 도로명주소 + " " + 상세 (공백 있음)
PAT_지번주소 지번주소 + 상세 (공백 <b>없음</b>)
PAT_영문도로명주소 상세 + " " + 영문주소 (순서가 <b>뒤바뀐다</b>)
레거시가 그렇게 찍는다. 맞춰야 같은 종이가 된다.
그리고 PAT_우편번호 가 돌려주는 것은 <b>우편번호 컬럼이 아니라 구역번호</b>다.
조회는 둘 다 가져오지만 레거시가 쓰는 것은 구역번호다.
주소를 못 찾으면 빈 값이 아니라 <b>상세주소만</b> 돌려준다 — 레거시와 같다.
## 왕복은 환자당 한 번
주소 태그가 넷이라 태그마다 읽으면 왕복이 넷이 된다.
주민번호와 같이 환자를 붙이는 시점에 한 번 읽어 해석기에 넘긴다.
읽기에 실패해도 던지지 않는다 — 상세주소만으로 값을 만들 수 있고 레거시도 그렇게 한다.
## 게이트
- dotnet test 336/336
- --edit-smoke 실패 0 — 이름 검사가 23종 전부 실제 태그임을 확인
- --db-patient ①~㉑ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"나머지도 전부 옮기자" — 추측하지 않고 386종을 본문 모양으로 분류했다.
## 실제 분포 (bzDataInterface.vb 386종)
자기 SQL 을 든 것 232 각각 별도 이식 — 본문 SQL 을 읽고 바인드로 옮기고 실DB 로 확인
MessageBox 를 띄우는 것 48 대화형이라 미리보기에서 <b>돌 수 없다</b>
문맥만 읽는 것 48 옮길 수 있는 것
기타(다른 라이브러리 호출) 55 하나씩 봐야 한다
HspCod 로 갈리는 것 3 병원별 분기
즉 "전부"는 태그 늘리기가 아니라 <b>232건의 데이터 접근 이식</b>이다.
48종 목록은 docs/TAG-PORTABLE-48.md 에 본문째로 뽑아 뒀다 — 다음 작업이 기계적이 된다.
## 이번에 옮긴 8종
순수 형식 가공만 하는 것들. Computed 표(태그 → 문맥 함수)를 새로 뒀다 —
컬럼 하나로 안 되는 것(두 컬럼을 보거나 잘라 붙이는 것)을 담는다.
PAT_생년월일_영문 · PAT_생년월일_영문_일월년도 자리만 바꿔 붙인다
PAT_연락처 휴대전화가 있으면 그것, 없으면 자택
PAT_연락처_뒷4자리 · PAT_휴대전화번호_뒷4자리 -, . 을 걷고 뒤 4자리
OCM_외래내원일자 · OCM_입원전환일자 ComAcpDtm·ComTrsDtm 앞 8자리
OCM_입원전환일시 12자리를 일시 형태로
자리수가 모자라면 <b>빈 값</b>이다. 잘라서 그럴듯하게 만들지 않는다.
## 이번에 뺀 것과 이유
PAT_우편번호 · PAT_도로명주소 · PAT_지번주소 · PAT_영문도로명주소
→ GetZipInfoByBuildingNumber_Simple 조회가 먼저 필요하다(왕복 추가)
OCM_담당의사 계열
→ GetCodInfDT + GetUidMst 두 조회를 거치고 입원/외래로 갈린다
OCM_외래내원일시
→ 별도 헬퍼(외래내원일시)의 형식을 아직 안 봤다
## 게이트
- dotnet test 336/336
- --edit-smoke 실패 0 — 이름 검사가 19종 전부 실제 태그임을 확인
(지어낸 이름 셋이 조용히 앉아 있던 적이 있어서 이 검사가 있다)
- --db-patient ①~㉑ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"검색 및 선택하면 시간이 너무 오래걸려" — 체감이 아니라 숫자였다.
진단에 시간을 재는 줄을 먼저 넣었다. 어디가 느린지 숫자 없이 고치면 엉뚱한 곳을 만진다.
## 실측 (--db-patient)
성명 검색 5,025ms → 441ms
ListVisits 5,000ms 내외 → 1~45ms
문맥 읽기 557ms (그대로 — 병목이 아니었다)
## 검색 — 보이지도 않는 값에 5초를 쓰고 있었다
최종내원일시와 재원여부를 파생표 두 개로 붙였는데 둘 다 P_ComInf 를 <b>통째로 GROUP BY</b>
한 뒤에야 조인된다. 조건에 맞는 환자가 몇 명이든 내원 테이블 전체를 집계했다.
그리고 최종내원일시는 <b>목록 열에 없다</b> — 차트번호·성명·주민번호·휴대전화·비고뿐이다.
아무도 보지 않는 값을 위해 5초를 쓰고 있었다. 뺐다.
재원여부는 비고 배지에 쓰므로 남기되, 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b>
따로 읽는다(FillInpatient). 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다.
차트번호는 바인드로 넘긴다 — 이어 붙이면 레거시의 주입 구멍을 되살린다.
## 내원 조회 — 원인을 한 번 잘못 짚었다
마스터 셋(M_DepMst·M_UidMst·M_InsMst)을 ROW_NUMBER 파생표로 접은 것이 문제라고 보고
상관 스칼라(MAX ... KEEP DENSE_RANK FIRST)로 바꿨다. 결과는 같고 정렬이 사라지는데
<b>시간은 그대로 5초였다.</b>
진짜 원인은 WHERE 였다 — <c>TRIM(A.ComChtNum) = :c</c> 는 컬럼에 함수를 씌워
인덱스를 못 타고 P_ComInf 전건을 스캔한다. 내원이 한 건인 환자도 5초가 걸린 이유다.
LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로
TRIM 등호를 함께 둬서 넘친 행을 걸러 낸다. 5,000ms → 1ms.
측정을 안 했으면 마스터 조인만 고치고 "고쳤다"고 보고했을 것이다.
그 변경도 유지한다(정렬이 사라지는 것은 이득이다) — 다만 그것이 원인은 아니었다.
## 게이트
- --db-patient ①~㉑ 전건 통과(판정 23건 유지 — 결과가 달라지지 않았다)
- dotnet test 336/336 · --edit-smoke 실패 0
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
문서(docs/RESNUM-DECRYPT.md)의 1→2→3 을 실행. 태그 7종 → 11종.
## 이 DB 가 암호화 병원이다
진단이 읽은 설정: 전체=Y 주민번호=Y.
앞 커밋에서 PAT_주민번호 를 뺀 판단이 맞았다 —
PatResNum 을 그대로 썼으면 <b>암호문이 종이에 찍혔다</b>.
## 앱은 키를 갖지 않는다
복호화는 오라클 패키지가 한다.
SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL
바인드 변수이고 접근 통제는 DB 권한이다. SCHILDREN 갈래(UDF_GETDECRESNUM_damo)도 함께 옮겼다.
앞 7자리가 평문이라 SUBSTR(...,1,7) 을 이어 붙이는 것을 빠뜨리면 앞자리가 사라진다.
## 설정을 못 읽으면 값을 만들지 않는다
"암호화 아님"으로 가정하면 PatResNum 을 평문처럼 써서 암호문을 찍는다.
PatientIdentity 는 예외 시 빈 문자열을 돌려주고 사유만 로그에 남긴다 — 값은 남기지 않는다.
주민번호를 만드는 자리를 한 곳으로 좁혀 뒀다(만드는 경로가 여럿이면 어디서 새는지 추적할 수 없다).
## 순수 계산은 표로 고정했다 (테스트 13건)
보정 ①(기관기호, SRCH·JINJU): 앞 6자리를 PatBthDay 로 갈아 끼운다.
원본은 If Not DateValidCheck(Substring(1,6)) 으로 감싸 있지만 <b>그 조건은 항상 참이다</b> —
DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 인데 6자리를 넘긴다.
여기서 "고쳐서" 진짜 날짜 검사를 넣으면 레거시와 다른 번호가 나온다(이 DB 가 SRCH 다).
이식은 동작을 옮기는 일이지 바로잡는 일이 아니다 — 그 사실을 테스트로 못 박았다.
보정 ②(외국인): 7번째 자리 0/9 를 PatBthDay 세기로 6/8·5/7 로. 국적코드가 있어야 걸린다.
성별: 7번째 자리 mod 2. PAT_성별 은 CoiCalCod 가 "24" 로 시작하면 PatSexTyp 을 먼저 쓴다.
## 추측하면 걸린다
M_DtsMst 컬럼을 DtsTabCod·DtlCod 로 추측해 ORA-00904 를 맞았다.
실제는 DtsTblCod·DtsDtlCod·DtsCod 다(dtCommonLib.vb:1136).
## 확인한 것과 못 한 것을 갈라 적는다
진단은 HIS 사용자 문맥 없이 도므로 병원코드가 비고, SRCH 기관기호 보정 갈래는
이 경로로 확인되지 않는다. 통과로 세지 않고 [미확인] 줄로 적는다 —
그 갈래는 순수 함수 테스트가 덮는다.
진단 리포트에 <b>값을 쓰지 않는다</b> — 길이와 "전부 숫자인가"만 적는다. 리포트는 파일로 남는다.
## 아직 안 한 것
나이(AgeCheck)는 병원별 규칙과 의료급여 전산관리번호 규약이 붙어 별도 작업이다.
개인정보 접근 로그(C_PdrInf)에 쓸지는 운영 DB 쓰기라 승인 전까지 하지 않는다.
## 게이트
- dotnet test 336/336 (주민번호 판정 13건 신규)
- --db-patient ①~㉑ 전건 통과 (⑲ 설정 · ⑳ 13자리 · ㉑ 성별)
- --edit-smoke 실패 0 (이름 검사가 파생 태그 4종까지 본다)
- --dialog-shots FAIL 0
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
환자 태그의 다음 관문. 성별(277건)·나이(204건)가 전부 복호화된 주민번호를 선행 조건으로 삼는다.
## 가장 중요한 발견
복호화가 <b>DB 안에서</b> 일어난다(dtCommonLib.vb:6960).
SELECT SUBSTR(:enc,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:enc,8,256)) FROM DUAL
바인드 변수이고 키는 오라클 패키지 안에 있다 — 접근 통제가 DB 권한이라는 뜻이고
그것이 있어야 할 자리다. <b>앱은 키를 갖지 않는다.</b>
이 성질을 깨는 이식(키를 상수로 옮기는 등)은 하지 않는다.
앞 7자리는 평문이고 8자리부터만 암호화되어 있다.
병원 SCHILDREN 만 UDF_GETDECRESNUM_damo 로 갈린다(2023-10-14 DAMO 전환).
## 어떤 병원에서 켜지는가
M_DtlMst(HSPCFG/PersonalDataEnc) 가 Y 일 때만
M_DtsMst(HSPCFG/PersonalDataEnc/ResNum) 를 한 번 더 본다(clsCommonLib.vb:5835-5841).
N 이면 P_PatInf.PatResNum 이 평문이고, Y 면 그 컬럼은 쓰지 말고 PatResEnc 를 복호화해야 한다.
앞 커밋에서 PAT_주민번호 를 뺀 이유가 정확히 이것이다.
## 복호화 뒤 보정이 더 있다
SRCH·JINJU 는 주민번호에 기관기호가 들어간 경우를 PatBthDay 로 고친다.
<b>이 개발 DB 가 SRCH 다 — 살아 있는 갈래다.</b>
외국인은 7번째 자리 0/9 를 PatBthDay 세기에 따라 6/8 또는 5/7 로 바꾼다.
둘 다 순수 문자열 계산이라 표로 고정할 수 있다.
## 성별은 지금 옮길 수 있다
7번째 자리 mod 2(0=F, 1=M). 암호화 여부로 갈라지지만 두 갈래 본문이 같다.
단 PAT_성별 에는 CoiCalCod 가 "24" 로 시작하면 PatSexTyp 을 쓰는 선행 분기가 있고,
CoiCalCod 는 P_CoiInf 라 이미 문맥에 들어와 있다.
## 나이는 지금 옮기지 않는다
AgeCheck(clsCommonLib.vb:735)는 7번째 자리로 세기를 정하는데 갈래가 많고
REDCROSS 별도 규칙과 의료급여 전산관리번호 규약이 붙는다. 보험코드까지 결과를 바꾼다.
원본 주석이 <b>1901~1919년생 외국인과 2001년 이후 보호기관 영아는 원리상 구분 불가</b>라고
명시한다(20080821).
절반만 옮기면 종이에 그럴듯한 나이가 찍힌다 — 아무도 의심하지 않으므로 빈칸보다 나쁘다.
사례 표를 먼저 만드는 별도 작업으로 둔다.
## 개인정보 접근 로그
레거시에 C_PdrInf 전용 경로가 있다(clsCommonLib.vb:28279).
주민번호를 복호화하기 시작하면 가장 민감한 필드를 다루는 것이므로 기록 범위를 정해야 한다.
그 표에 쓰는 것은 운영 DB 쓰기라 사용자 승인이 필요하다 —
정하기 전에는 파일 로그에 사실만 남기는 쪽이 안전하다.
코드 변경 없음(문서만). 게이트는 앞 커밋 상태 그대로다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"환자 태그이지만 아직 옮기지 않았습니다 엄청 많은데?" — 맞다. 그리고
옮겼다고 적어 둔 것도 절반이 거짓이었다.
## 지어낸 이름 셋
PAT_휴대전화 · PAT_전화번호 · PAT_주소 는 <b>실존하지 않는 태그</b>다.
컬럼 이름만 보고 태그 이름을 추측해 표에 넣었고, 이름이 안 맞으면 매칭이 안 되므로
값도 안 나오고 오류도 안 난다 — 화면에는 "아직 안 옮겼습니다"로 보인다.
실제 이름은 PAT_휴대전화번호 · PAT_자택전화번호 이고 PAT_주소 는 없다.
그래서 진단에 이름 검사를 넣었다 — 옮긴 이름이 전부
LegacyTagCatalog.DataInterfaceTags 에 있는지 본다. 대조군(없는 이름이 실제로 걸리는지)도 함께 둔다.
## PAT_주민번호 는 빼야 했다
레거시는 PatResNum_DEC(복호화 컬럼)를 읽는다(bzDataInterface.vb:87).
P_PatInf 의 PatResNum 을 그대로 쓰면 <b>암호화 병원에서 암호문이 종이에 찍힌다</b>.
빈칸보다 나쁜 값이라 표에서 뺐다.
## 실제로 맞게 돌던 것은 2종이었다
PAT_이름 · PAT_차트번호. 앞서 "6종"이라고 보고한 것은 틀렸다.
## 이번에는 함수 본문을 읽고 넣었다
기계적으로 옮길 수 있는 것은 <b>본문이 컬럼 하나를 그대로 돌려주는</b> 5종뿐이다
(추출해서 셌다). 거기에 형식이 하나 붙는 생년월일을 더해 7종:
PAT_이름 · PAT_영문이름 · PAT_휴대전화번호 · PAT_자택전화번호 · PAT_여권번호
PAT_차트번호(내원의 ComChtNum — moPatInfoBiz.ChtNum 이 그것이다, :70)
PAT_생년월일(PatBthDay → YYYY-MM-DD, :188-198)
차트번호를 PatInf 에서 ComInf 로 고쳤고, 진단의 가상 문맥도 같이 고쳤다 —
가상 문맥이 옛 매핑을 쓰고 있으면 실제와 다른 것을 검사한다.
## 왜 나머지가 많이 남는가 (노력 부족이 아니다)
- PAT_성별/한글성별 : CoiCalCod 가 "24" 로 시작하면 PatSexTyp, 아니면
GetSexAge(PatResNum_DEC) 로 주민번호에서 뽑는다(:283-316) — 복호화가 선행 조건이다.
- PAT_나이 계열 : 병원 코드로 갈라지고 CGCH 는 자기 SQL 을 들고 있다(:373-).
- 그 밖 238종 : 함수 본문에 자기 SQL 이 있다.
즉 다음 관문은 태그를 더 옮기는 것이 아니라 <b>주민번호 복호화</b>다.
그것이 풀리면 성별 계열이 따라 열린다. 짐작으로 채우면 종이에 그럴듯한 값이 찍히고,
그건 아무도 의심하지 않는다.
## 게이트
- dotnet test 323/323
- --edit-smoke 실패 0 (이름 검사 + 대조군 2건 신규)
- --db-patient ①~⑱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
테마 버튼 오른쪽에 user 아이콘. 전에는 미리보기 창 안에만 있어서
쿼리 편집처럼 환자 값이 필요한 다른 화면에서는 고를 방법이 없었다.
## 방향을 뒤집었다
전에는 미리보기 창이 환자를 들고 있고 세션에 <b>밀어 올렸다</b>.
그러면 제목줄에서 바꿀 때 창이 모르고, 세션과 창이 어긋나는 경로가 생긴다.
이제 모든 진입점이 PatientSession 에만 쓰고, 창들은 Changed 를 <b>듣는다</b>.
제목줄·미리보기·쿼리 편집 어디서 바꿔도 나머지가 따라온다.
미리보기 창은 열 때 세션의 환자를 물려받는다 —
창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
## 아이콘이라 상태를 색으로 알린다
늘 보이는 자리라 글자를 둘 폭이 없다. 고르면 강조색, 이름은 툴팁에 둔다 —
제목줄에 환자 이름을 상시 노출하지 않는다.
이미 고른 상태에서 누르면 <b>메뉴</b>가 뜬다(바꾸기 / 해제).
"누르면 해제"로 두면 늘 보이는 아이콘이라 실수로 지운다.
## 말뿐인지 값인지 확인했다
제목줄 아이콘은 세션만 건드린다. 열려 있는 창이 그것을 듣지 않으면
아이콘은 켜지는데 종이는 그대로다 — "설정했는데 안 먹는다"가 된다.
그래서 판정은 <b>창의 메서드를 부르지 않고</b> 세션에만 써서 종이가 따라오는지 본다.
구독을 떼는지도 본다. 정적 이벤트라 안 떼면 창이 회수되지 않고
죽은 창이 계속 그리려 든다 — 닫은 뒤 세션을 흔들어 예외 없이 지나가는지 확인한다.
## 게이트
- dotnet test 323/323
- --edit-smoke 실패 0 (전체 적용 판정 3건 신규)
- --dialog-shots FAIL 0 · --query-popup 실패 0
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
전역 TextBlock 스타일이 TextOptions.TextFormattingMode="Ideal" 을 박고 있었다
(DesignerTheme.xaml:40). Ideal 은 WPF 기본값이고 글리프를 픽셀 격자에 맞추지 않아
11~14px UI 글자가 눌린 듯 흐려 보인다.
## 왜 두 번이나 못 잡았나
<b>ClearType 과 다른 축이다.</b> Ideal 이든 Display 든 색 번짐은 똑같이 생긴다 —
그래서 색 번짐을 세는 --cleartype 으로는 원리상 구분이 안 된다.
표면을 늘려 가며(카드 제목 → 본문 → 목록) 전부 "켜짐"을 확인했고 게이트는 초록불이었는데
사용자 눈에는 계속 흐렸다. 측정하고 있던 것이 물어야 할 것이 아니었다.
이번에는 픽셀이 아니라 <b>속성 값</b>으로 잠근다 —
G·H·I 세 표면에서 GetTextFormattingMode() == Display 를 확인한다.
## 화면과 종이를 상속으로 가른다
- 창(ThemedWindow)에 Display. 상속 속성이라 TextBlock·TextBox·버튼·표 글자에 모두 닿는다.
- 종이는 Ideal. PageView 의 용지 Grid 와 PrintService 의 캔버스가 각각 명시하고
그 안의 글자가 상속한다. 인쇄 지면 자릿수가 바뀌면 레거시와 다른 종이가 된다.
전역 TextBlock 스타일에서는 이 속성을 <b>빼야</b> 했다.
스타일 세터는 상속값을 이기므로, 거기에 값을 박으면 종이 안의 글자까지 그 값이 되어
용지 Grid 가 정한 Ideal 을 덮는다. 지금은 두 쪽 다 상속으로만 정한다.
## 종이가 끌려가지 않았는지 두 겹으로 확인
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- --edit-smoke 에 '종이 글자는 Ideal 을 유지한다' 를 추가.
md5 는 서식 하나만 보므로 속성 값으로도 못 박았다.
## 게이트
- dotnet test 323/323
- --cleartype 실패 0 (색번짐 3표면 + Display 판정 3건 신규)
- --edit-smoke 실패 0 · --query-popup 실패 0 · --dialog-shots FAIL 0
- --db-render md5 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
## ① 요약이 환자를 두 번 적고 있었다
아래 버튼이 이미 "환자 해제 — 강한(0001381872) · 외래 2019-03-28 09:22 · 기준 …" 을
그대로 보여 주는데 요약도 같은 것을 다시 적었다. 두 줄로 늘어 결과 표를 밀어내고,
화면에 환자 이름이 필요 이상으로 여러 번 남았다.
환자가 있을 때는 침묵한다 — 버튼이 이미 말한다.
없을 때만 "변수 NULL — 구문만 검사" 를 남긴다(그 결과는 값이 아니라는 뜻이고,
그건 버튼 글자로는 알 수 없다). 빈 조각은 걸러 낸다 — 안 걸러 내면
"성공 · · 경고" 처럼 가운뎃점이 떠 있는다.
## ② 앞 수정이 부족했다 — 표면마다 경계가 따로 있다
카드 DockPanel 에만 힌트를 걸어 두고 고쳤다고 했는데 정작 SQL 글자는 계속 뭉개졌다.
본문은 SqlHighlightLayer 가 그리고 그 사이에 또 다른 중간 서피스가 있으며,
치환 변수 목록은 ListBox 템플릿 안에 ScrollViewer 를 품고 있다 —
카드에 건 힌트는 그 <b>바깥</b>이라 듣지 않는다(진단 표본 I 가 정확히 그 경우다).
- SQL 편집 영역: 불투명 B.Surface 를 가진 안쪽 Border 에 힌트
- 검증 결과 창(TrialPane): 배경 + 힌트
- 치환 변수 목록: ItemsPanel 을 불투명하게 만들어 ScrollViewer <b>안쪽</b>에 힌트(표본 J).
VirtualizingStackPanel 을 쓴다 — StackPanel 로 바꾸면 변수 120개가 전부 실체화된다.
## 게이트가 초록불인데 화면만 틀린 것을 또 만들지 않기 위해
앞 커밋에서 실제 창 표본을 넣었지만 <b>카드 제목만</b> 쟀다.
제목은 켜졌고(150) 본문은 꺼져 있었는데 게이트는 통과했다 — 같은 함정을 한 번 더 밟았다.
사람이 실제로 들여다보는 표면을 다 재도록 바꿨다:
G 카드 제목 색번짐 150
H SQL 본문 색번짐 8,114
I 치환 변수 목록 색번짐 22,047
대조군(B Effect·C1 힌트를 Effect 요소에·I 힌트 바깥 ScrollViewer)은 모두 0 이므로
이 숫자들은 뜻이 있다.
## 게이트
- dotnet test 323/323
- --cleartype 실패 0 (실제 창 표본 1개 → 3개)
- --query-popup 실패 0 · --edit-smoke 실패 0 · --dialog-shots FAIL 0
(변수 목록 그룹 표시 유지 확인 — ItemsPanel 교체가 그룹을 깨지 않았다)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"아직도 안됨, 그리고 글씨도 흐리게보이고" — 둘 다 맞다.
## ① 안 되는 게 아니라 고를 수가 없었다
ORA-00936 은 사라졌고 검증 실행은 성공한다. 그런데 0행이다.
화면이 이유를 말하고 있었다 — "환자를 고르지 않아 변수를 NULL 로 두고 구문만 검사".
그런데 <b>환자 선택이 미리보기 창 안에만 있었다.</b>
쿼리를 고치는 사람은 값 확인을 할 방법이 없고, 늘 0행이면 그건 "안 된다"로 읽힌다.
사유를 정확히 말해도 고칠 수단이 없으면 안내가 아니다.
검증 실행 옆에 '환자 선택...' 을 뒀다. 고르면 치환 변수에 실제 값이 들어가
구문뿐 아니라 값까지 확인된다. 세션 하나를 공유하므로 미리보기와 같은 환자다.
이미 고른 상태에서는 버튼이 '환자 해제 — <이름>' 이 된다 —
버튼 하나에 고르기·바꾸기·해제를 다 넣으면 무엇이 일어날지 예측할 수 없다.
## ② 카드 그림자가 ClearType 을 끄고 있었다
이 세션에서 미뤄 둔 항목이다. Card 스타일이 Effect(그림자)를 걸고,
Effect 는 하위 트리 전체를 중간 서피스로 보내 ClearType 을 끈다 —
13px 한글이 회색 안티에일리어싱으로 뭉개진다.
힌트는 Effect 가 걸린 Border 에 걸면 듣지 않는다(--cleartype 표본 C1 이 그것이다).
안쪽의 불투명 배경 요소에 걸어야 한다(C2). 카드 안 DockPanel 에 배경과 힌트를 줬다.
## 기법이 듣는 것과 걸어 둔 것은 다른 일이다
--cleartype 은 그동안 <b>합성 도형</b>만 쟀다. A~J 전부 통과했는데도
실제 창에는 기법을 안 걸어 둬서 글씨가 계속 뭉개졌다 —
게이트가 초록불인 채로 사용자가 보는 화면만 틀려 있었다.
그래서 실제 쿼리 편집 창을 띄워 카드 제목을 재는 표본 G 를 넣었다.
색번짐 150 / 글자픽셀 188 → 켜짐. 고치기 전 조건은 표본 B(Effect + 힌트 없음)와
같고 그건 0 이다. 창은 화면 안(0,0)에 띄운다 — BitBlt 은 실제로 그려진 픽셀만 읽으므로
화면 밖에 두면 아무것도 못 잰다.
## 게이트
- dotnet test 323/323
- --cleartype 실패 0 (A~J + G(실제 창) 신규)
- --query-popup 실패 0 · --edit-smoke 실패 0 · --dialog-shots FAIL 0
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
select * from P_PATINF where PATCHTNUM = <<…bzPatientInfo.ChtNum>>
→ ORA-00936: 누락된 표현식
원인은 오라클이 아니라 우리 쪽이었다. 검증 실행이 <<…>> 를 전부 빈 문자열로
지운 뒤 보냈다 — "WHERE PATCHTNUM = " 가 된다.
런타임이 값 없을 때 그렇게 한다는 이유였지만(clsMDataTable.vb:67-71),
그 결과 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 통과할 수가 없었다.</b>
정작 이 기능이 존재하는 이유가 그런 쿼리들이다.
## 두 갈래로 나눴다
환자를 골랐으면 <b>실제 값</b>으로 치환한다 — 구문뿐 아니라 값까지 확인된다.
안 골랐으면 빈 자리를 NULL 로 채운다. 따옴표 안이든 밖이든 문법이 성립하고
0행이 나오므로 테이블·컬럼 오타와 구문 오류는 그대로 걸린다.
자리표시는 QuerySubstitution.Apply 의 선택 인자다(기본은 레거시와 같이 빈 문자열).
실행 경로(미리보기)는 쓰지 않는다 — 거기서는 레거시와 같은 SQL 이어야 한다.
내역(Hits)에는 실제 판정을 남긴다. 자리표시로 채운 것을 "값이 있었다"로 적으면
화면이 "값이 나왔다"고 말하게 되고, 그게 이 기능에서 가장 위험한 거짓말이다.
## 환자를 세션으로 올렸다
환자는 미리보기 창에서 고르는데 검증 실행도 같은 환자로 돌아야 한다.
창마다 따로 들고 있으면 같은 서식을 두 화면에서 서로 다른 환자로 보게 되고
그건 화면으로 구분할 수 없다. PatientSession 하나만 둔다(디스크에 쓰지 않는다).
## 무엇으로 돌렸는지 말한다
요약 첫 줄이 "환자 …의 실제 값으로 실행" 또는 "환자를 고르지 않아 변수를 NULL 로
두고 구문만 검사"다. 이 줄이 없으면 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다.
따옴표 밖에 놓인 변수도 짚는다 — 값이 원문 그대로 박히므로(clsMDataTable.vb:113)
문자 컬럼과 비교하려면 서식 쪽에서 '<<…>>' 로 감싸야 한다. 이 배선의 가장 흔한 실수다.
질문에 딸려 온 쿼리가 정확히 그 경우였다.
## 대조군이 함정을 잡았다
'따옴표로 감싸면 경고가 없다'가 처음에 통과했는데, 경고가 <b>아예 안 떠서</b>
양쪽이 다 "없음"이었다. 원인은 내 가드다 — 토큰이 문장 끝에 오면 건너뛰게 써 놨고,
WHERE 절 마지막이 가장 흔한 자리다. 경계를 '따옴표 없음'으로 보도록 고쳤다.
대조군 없이 긍정 판정만 있었으면 이 기능은 조용히 아무것도 안 하고 있었을 것이다.
## 게이트
- dotnet test 323/323
- --query-popup 실패 0 (⑨-b 5건 신규: 구문 검사가 돌고 · 자리가 비지 않고 ·
근거를 밝히고 · 따옴표 밖을 짚고 · 감싸면 안 짚는다)
- --edit-smoke 실패 0 · --dialog-shots FAIL 0
- --db-patient ①~⑱ 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
서식은 항상 목록에서 골라 연다는 결정.
## 없앤 진입점 넷
- 파일 메뉴 '새 서식(N)'
- Ctrl+N
- 탭 흐름 옆 '+' 버튼(DesignerTheme.xaml)
- 빈 화면의 '새 서식' 버튼
시작 시 빈 문서를 만들던 것도 없앴다. 그게 실제 이유다 —
코드 없는 문서는 DB 에 저장할 수 없고(FormId 가 "NewSheet"), 사용자는
한참 그려 넣은 뒤에야 그 사실을 알게 된다. 만들 수 있게 두는 것 자체가 함정이었다.
이제 기동 인자에 서식 코드가 없으면 아무것도 열지 않고 서식 목록 탭을 띄운다.
빈 화면 안내도 '서식 목록에서 열기' 하나로 줄였다(F4 · Ctrl+O 병기).
CreateNew 와 파일 저장·열기 코드는 남긴다 — 접속 없이 도는 진단이 그 경로를 쓴다.
UI 진입점만 없앤 것이고, 이는 'DB에서 열기' 창을 지울 때와 같은 방식이다.
NewFileCommand 를 쓰던 --dialog-shots 는 AttachBlankForDiagnostics 로 바꿨다
(이름에 '진단 전용'을 박아 UI 에서 다시 부르지 못하게 했다).
## 없애자마자 드러난 것
문서가 아예 없는 상태가 <b>기본</b>이 되니 속성 패널에 정렬 바와 간격값이
그대로 남아 있었다. CurrentDesigner.Inspector 가 null 이면 안쪽 Visibility
바인딩이 전부 실패하고, WPF 는 <b>실패한 바인딩을 기본값(Visible)으로 떨군다</b> —
Collapsed 가 아니다. 선택이 없다는 안내와 정렬 바가 동시에 떠 있었다.
열린 서식이 없으면 인스펙터를 접는다. 그리고 이 회귀는 값으로 잡히지 않으므로
시각 게이트에 대조군을 넣었다 —
00-main-default(문서 있음 → 인스펙터 보임) 대 00d-main-empty(문서 없음 → 접힘).
대조군 없이 빈 화면만 찍으면 "원래 그랬던 것"과 구분되지 않는다.
## 게이트
- dotnet test 323/323
- --edit-smoke 실패 0
- --dialog-shots FAIL 0 (00d-main-empty 추가)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"태그와 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>
개발자 계정으로 환자 선택을 누르면 "전산실 소속만 가능합니다"가 떴다.
PatientPreviewPolicy 가 ModifyPolicy.CanModify 를 그대로 호출하고 있었기 때문이다.
## 근거를 다시 읽었다
레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이다(frmSheetDesigner.vb:1030-1060).
TK_ExportXML / TK_ImportXML 은 MSYS 계정 문이고, TK_PREVIEW 에는
가시성 조건이 하나도 걸려 있지 않다 — 서식생성기를 열 수 있으면 쓸 수 있다.
즉 이 제약은 레거시에서 옮긴 것이 아니라 내가 "무게가 비슷하다"고 판단해
붙인 것이다. 사용자가 정한 것도 아니었다 — 환자 접근에 대한 결정은
"전체 검색 + 감사 로그"였고 부서 제한은 거기에 없다.
## 두 일의 무게가 실제로 다르다
수정은 지금 쓰이는 서식을 제자리에서 바꿔 병원 전체에 즉시 영향을 준다.
미리보기는 이미 그 사람이 볼 수 있는 기록을 읽기만 한다.
그리고 이 문은 막아서 더 위험해지는 쪽이었다. 서식을 만드는 사람이
자기 서식을 검증할 수 없으면 값이 틀린 서식이 그대로 운영에 올라간다.
태그가 정확한 값을 뽑는지 확인하는 것이 이 기능을 만든 이유였는데
그 확인을 만든 사람에게서 빼앗고 있었다.
## 바꾼 것
CanPreviewPatient 는 인증만 요구한다 — 미인증이면 감사 로그에 남길 주체가 없고
주체 없는 환자 조회는 남기면 안 되는 일이다. 그것이 유일한 조건이다.
병원·부서 인자는 받아 두고 쓰지 않는다. 병원별 규칙이 생기면 호출부를 고치지 않고
여기서 받을 수 있어야 한다.
안전장치는 막는 것이 아니라 감사 로그다. 그래서 테스트도 무게를 옮겼다 —
클래스 주석의 "감사와 권한이 유일한 장치"를 "감사 로그가 유일한 장치"로 고쳤고,
감사 한 줄의 모양(주체가 있는가·이름이 새지 않는가)이 가장 중요한 판정이 된다.
## 대조군
'수정_권한은_그대로_좁게_남는다' 를 새로 넣었다. 미리보기를 연 것이
수정까지 연 것이 되면 안 되고, 이 대조가 없으면 위 완화가 조용히
ModifyPolicy 까지 번져도 알 수 없다.
게이트: dotnet test 313/313 (미리보기 판정 1건 → 2건으로 갈라짐)
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
전 커밋까지 환자 조회·시각 판정·권한 규칙은 다 있었지만 화면에 붙은 것이
하나도 없었다. 미리보기를 열면 환자 선택이 없고, 태그는 전부 사유만 찍혔다.
## 만든 사슬
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>
미리보기 레거시 호환 계획 A3 의 세 번째 조각. 순수 함수 + 테스트 9건.
<b>왜 이것부터인가.</b> 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 —
진료과·담당의(P_CodInf), 보험 자격(P_CoiInf), 병동·병실(P_CowInf) 모두
:AdpDtm BETWEEN StrDtm AND EndDtm 이다.
시각이 한 칸 어긋나면 조인이 0행이 되고 <b>태그가 통째로 빈 값</b>이 된다 —
값이 틀리는 것이 아니라 아무것도 안 나오고, 사용자는 "미리보기가 고장났다"고 읽는다.
갈래가 여섯이고 각각 경계 조건이 다르다:
외래 → 접수일시 그대로(자격 구간을 보지 않는다)
입원 First → 자격 시작과 접수 중 <b>늦은</b> 쪽
입원 Last, 재원 중(CoiEndDte=29991231) → 지금. 단 입원 전환이 미래면 접수 시각
입원 Last, 퇴원 → 자격 종료와 퇴원 중 <b>이른</b> 쪽
호출자 지정(8자리/12자리) → 내원 구간 안으로 <b>가둔다</b>
해석 불가 → 자동 판정으로 떨어진다(빈 값을 내보내면 조회가 전부 0행이다)
<b>저장소에 묻지 않고 순수 함수로 뺐다.</b> 안에 넣으면 실DB 없이는 한 갈래도 확인할 수 없다.
지금은 여섯 갈래를 전부 표로 고정했다.
시각 비교는 문자열 사전순이다 — 레거시가 그렇게 하고, 고정폭 YYYYMMDDHHMM 은 사전순이 곧 시간순이다.
DateTime 으로 바꾸면 잘못된 값(빈 문자열·공백)에서 예외가 난다.
CHAR 잔여 공백을 흡수한다 — 안 하면 사전순 비교가 어긋난다(검사로 못 박음).
게이트: 테스트 312/312(신규 9), --edit-smoke 0실패, --dialog-shots FAIL 0, 빌드 경고 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
<b>남은 것</b>: 이 시각으로 5행을 읽는 PatientContextStore, 환자 선택 UI,
그리고 그 문맥을 쓰는 태그 해석기.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획 A3 의 조회 계층. --db-patient 로 실DB 검증까지.
<b>레거시에서 옮기지 않은 것 셋</b>(dtPatVisitList.vb:42, 133):
① <b>바인드 변수가 하나도 없다.</b> 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다 —
성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다. 전부 OracleParameter 로 바꿨고
와일드카드는 SQL 리터럴이 아니라 <b>파라미터 값</b>에 붙인다.
② <b>행 제한이 없다.</b> ROWNUM/FETCH 어디에도 없어서 성명 한 글자면 전 환자가 클라이언트로 온다.
ROWNUM 상한 200 을 건다.
③ <b>TRIM 정규화가 없다.</b> CHAR 고정폭 잔여 공백에 걸린다.
<b>컬럼을 다 가져오지 않는다.</b> 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고
M_ZipMst 를 조인해 화면 12열을 채운다. 미리보기 환자 선택에 필요한 것은 사람을 특정할 최소한이다 —
개인정보는 덜 읽는 쪽이 낫다. 주민번호도 UDF_GetMaskedResNum 결과만 읽는다
(레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄운다).
내원 목록의 <b>금액 6열은 옮기지 않았다</b> — 레거시도 값을 받지 못해 화면에서 늘 비어 있다.
기간 PK 마스터(M_DepMst·M_UidMst·M_InsMst)는 ROW_NUMBER … Rn = 1 로 접는다.
안 접으면 같은 내원이 여러 번 뜬다 — 검사 ⑦ 이 그것을 본다.
<b>UID 는 Oracle 내장 함수다.</b> 의사 마스터 별칭을 Uid 로 두었더니 ORA-00909(인수 개수 오류)가 났다 —
Uid.Nam 을 함수 호출로 파싱한다. 컴파일은 통과했고 실DB 에서만 드러났다.
--db-patient 가 없었으면 환자를 고르는 화면에서 처음 터졌을 것이다.
실측(테스트 접속): 성명 '김' 200건(상한), 네 갈래 전부 예외 없음,
빈 검색어 0건, 주민번호 1글자 0건, 따옴표 주입 0건, 상한 동작, 내원 중복 없음 — 7/7.
게이트: 테스트 303/303, --edit-smoke 0실패, --dialog-shots FAIL 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
<b>남은 것</b>: 환자 선택 UI, ComNum→문맥 5행(P_PatInf/P_ComInf/P_CodInf/P_CoiInf/P_CowInf)과
적용일시(AdpDtm) 결정 규칙, 그리고 그 문맥을 쓰는 태그 해석기.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 A3 첫 조각. DB 조회는 조사 중이라 규칙과 감사부터 세운다.
<b>레거시에서 옮기지 않는 것이 요점이다.</b>
· 폼이 뜰 때마다 인가코드를 InputBox 로 묻는데 <b>통과값 3개가 소스에 평문</b>이다
(frmSheetDesigner.vb:913)
· 환자를 검색조건 없이 <b>운영 전체</b>에서 찾는다(:636)
· 누가 어느 환자를 열었는지 <b>남기지 않는다</b>(감사 0건)
셋을 그대로 옮기면 신규 앱이 최악을 물려받는다.
검색 범위는 레거시와 같게 두기로 했다(전체 검색 + 감사 로그).
그래서 <b>남기는 쪽</b>이 안전장치의 본체다 — 조회를 막지 않는 대신 흔적을 남긴다.
AppLog.Audit 은 FIXED 레벨이라 어느 로그 설정에서도 남는다.
권한 축은 ModifyPolicy 와 같은 것을 쓴다. 환자 기록을 보는 일은
지금 쓰이는 서식을 제자리에서 고치는 일과 무게가 다르지 않다.
미인증이면 막는다 — 주체가 없으면 감사에 남길 것이 없고, 남길 수 없는 조회는 하지 않는다.
<b>감사 한 줄에 환자 이름은 넣지 않는다.</b> 내원번호로 되짚을 수 있고,
로그는 오래 남고 접근 통제가 DB 보다 느슨하다. 검사로 못 박았다.
게이트: 테스트 303/303(신규 4), --edit-smoke 0실패,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 A2. 값이 더 나오는 효과는 <b>최대 7건</b>이고,
본체는 "왜 값이 안 나오는지"가 맞아지는 것이다.
<b>① Inital</b> — 분류 규칙이 EndsWith("InitialDatatable") 인데 실제 태그 3종은
_InitalDatatable 이다(LegacyTagCatalog.cs:184, 194, 196). 레거시가 그렇게 적어 두었다.
맞춤법을 고치는 것이 아니라 실재하는 표기를 받는 것이다.
안 받으면 그 셋이 표가 아니라 환자 문맥으로 분류되어,
화면이 "환자 정보가 있어야 값이 나온다"는 <b>틀린 사유</b>를 말한다 —
사용자는 환자를 골라 보고 왜 안 나오는지 계속 찾게 된다.
검사에 "이 표기가 카탈로그에 실재하는가"를 함께 넣었다. 표기가 바뀌면 검사가 헛돈다.
<b>② ETC_건강보험증번호_Refer</b> — 본문 34줄이 전부 주석 처리되고 Return "" 만 남았다
(bzDataInterface.vb:15352). 운영 2건. Dead 표에 없어서 화면이 "환자 정보가 있으면 나온다"고 말했는데,
환자를 골라도 영원히 안 나온다.
<b>앞선 보고를 정정한다</b>: "카탈로그 오분류 45종 122건 회수"는 틀렸다.
재계측하면 최대 40종 7건이고, 근거로 들었던 _병원직인(82건)·_병원로고(22건)는
이름에 '직인'·'로고' 가 들어가 이미 Image 로 분류된다 — 누수가 아니었다.
게이트: 테스트 299/299(신규 1), --edit-smoke 0실패, --dialog-shots FAIL 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 A1.
지금까지 미리보기는 태그를 <b>글자 그대로</b> 찍었다 — PAT_이름 이 종이에 그렇게 인쇄됐다.
값이 흐를 관이 아예 없었다.
<b>해석기를 인터페이스로 둔다.</b> 오늘 값이 나오는 것은 서버 시각·로그인 사용자 16종
(운영 사용 579건, 9.1%)뿐이고 나머지 368종은 환자·내원 문맥이 있어야 한다.
그 문맥이 생기면 <b>같은 자리로</b> 더 강한 해석기가 들어온다 — 관을 지금 파 두고 해석기만 갈아 끼운다.
A1 의 값어치는 9.1% 가 아니라 그 관이다.
<b>못 만든 값을 감추지 않는다.</b> 값이 나오면 값을, 안 나오면 사유를 대괄호로 감싸 그린다 —
[PAT_차트번호 — 환자·내원 정보가 있어야 값이 나오는 태그입니다].
빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다.
레거시도 같은 문제를 같은 이유로 다뤘다 — 실패한 컨트롤을 노란색으로 칠했다
(ucLoadSheetBase.vb:2300-2308). 그 장치가 있다는 것 자체가 현장 실패가 흔하다는 방증이다.
주입은 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 만든다.
<b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b>
DB 는 창당 한 번만 읽는다(재료 왕복 2회). 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다 —
레거시가 태그마다 쿼리를 돌려 태그 40개 서식에 왕복 40회를 하는 것과 같은 실수다.
<b>단정은 종이에 찍힌 글자를 본다.</b> VM 속성만 보면 템플릿이 그 값을 쓰는지 알 수 없다 —
배선과 결과는 다른 문제이고, 이 저장소에서 그 둘이 여러 번 갈렸다.
검사용 해석기를 따로 둬 접속 없이도 같은 답이 나오게 했다.
게이트: 테스트 298/298, --edit-smoke 0실패(신규 3), --dialog-shots FAIL 0,
--modal-check 0실패, --cleartype 11/11, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 P0-3. P0 세 항목이 이걸로 끝난다.
레거시 미리보기와 레거시 인쇄가 서로 다르다 —
미리보기는 PrintOutPut=False 컨트롤을 <b>그대로 보여 주고</b>(그 속성은 PrintMe 안에만 있다,
Label.vb:454-457) 인쇄만 뺀다. SheetMe 는 인쇄 쪽을 골라 테스트로 못 박아 뒀었다.
둘 다 확인해야 하므로 미리보기 창이 보기를 가른다. 기본은 인쇄 기준 —
저장 전에 확인하려는 것이 대개 종이 결과다.
Hidden(디자이너 임시 숨김)과 MDataTable(런타임 비가시)은 보기와 무관하게 항상 뺀다.
레거시 미리보기에서도 안 보이는 것들이다 — 보기가 가르는 것은 Visible/PrintOutPut 뿐이다.
인쇄 경로는 언제나 필터를 건다. 인쇄는 인쇄다.
<b>단정은 "돌아오는가"를 본다.</b> 필터를 켜서 뺀 것이 끄면 다시 나와야 한다 —
안 나오면 전환이 배선만 된 것이다. 컨테이너 안까지 세므로 P0-1 의 투영도 함께 검증된다.
<b>XAML 파싱 중에 이벤트가 발생했다.</b> 토글의 IsChecked="True" 가 파스 시점에 Checked 를 쏘는데,
토글이 트리에서 앞에 있어 그때 PagesHost 는 아직 null 이다 →
InitializeComponent 안에서 NullReferenceException. 가드를 넣고 이유를 적었다.
--edit-smoke 가 잡았다. 없었으면 미리보기를 여는 순간 앱이 죽었을 것이다.
게이트: 테스트 298/298, --edit-smoke 0실패(미리보기 7건), --dialog-shots 글자 2,653개 FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 P0-2. 환자 값이 들어가기 <b>전에</b> 반드시 고쳐야 하는 항목이다.
<b>① 창이 여러 장 쌓였다.</b> 모덜리스인데 누를 때마다 새로 만들어서,
각각이 서로 다른 시점의 서식을 들고 겹쳐 있었고 어느 것이 지금 것인지 화면으로 구분할 수 없었다.
환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 B 로 바꿔도 옛 창이 A 를 계속 띄운다.
이제 하나만 뜨고, 이미 있으면 지금 문서로 갈아타 앞으로 온다.
<b>② 생성자에서 한 번만 그렸다.</b> 컨트롤을 추가·삭제하거나 숨김·인쇄출력을 토글해도
창은 옛 그림을 계속 보여 줬다 — 저장 전에 결과를 확인하려고 여는 창이
<b>구조적으로 거짓말</b>을 하고 있었다.
페이지·컨트롤 추가/삭제는 즉시(CollectionChanged), 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다.
즉시 갱신이 안 되는 변경이 남아 있으므로 새로 고침 버튼도 함께 뒀다 — 자동에 기대게 하지 않는다.
제목에 서식명을 넣는다. 창이 하나뿐이라 무엇을 보고 있는지 제목이 말해야 한다.
<b>같은 함정에 세 번째로 걸렸다.</b> 단정이 52 → 41 로 나왔다 — 재빌드는 됐는데
새 자식이 아직 실체화되지 않아 요소 수가 오히려 줄어 보인 것이다.
UpdateLayout 을 돌리고 세니 통과했다. WPF 시각 트리를 세는 검사는 <b>반드시</b> 레이아웃을 먼저 돌려야 한다.
진단(--dialog-shots)은 생성자를 직접 부른다 — 단일 인스턴스 등록을 건너뛰어야
샷마다 새 창을 찍을 수 있다. 앱 코드는 ShowFor 만 쓴다는 것을 주석에 못 박았다.
게이트: 테스트 298/298, --edit-smoke 0실패(신규 1), --dialog-shots 글자 2,651개 FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
미리보기 레거시 호환 계획의 P0-1. 오늘 실제로 틀리고 있던 유일한 항목이다.
BuildPageVisual 은 page.Controls <b>최상위만</b> 훑는데, Panel·GroupBox 템플릿이
ItemsSource="{Binding Children}" 로 자식을 스스로 그린다. 그래서 최상위를 아무리 걸러도
컨테이너 안은 그대로 나왔다 —
① Panel 안 컨트롤은 '인쇄 출력'을 꺼도 인쇄됐다
② 컨테이너 안 MDataTable 은 파란 DB 원통 배지가 종이에 찍혔다
얼마 전 인쇄 필터를 넣으면서 이 구멍을 못 봤다.
<b>먼저 실패시키고 고쳤다.</b> 단정을 넣고 돌리니 요소 17 → 17. 그 red 가 두 번 일했다.
<b>첫 접근이 틀렸고 측정이 잡았다.</b> 컨테이너 VM 에 '인쇄 필터' 플래그를 켰다 끄는 방법을 썼는데
듣지 않았다 — WPF 는 템플릿 자식을 <b>레이아웃 시점</b>에 만드는데 BuildPageVisual 은
프레젠터만 만들어 두고 반환하므로, 자식이 실체화될 때는 이미 finally 가 플래그를 껐다.
플래그 수명을 인쇄 비주얼 전체로 늘리면 그동안 캔버스에서도 자식이 사라진다(VM 공유).
그 접근은 되돌렸다.
<b>택한 방법</b>: 같은 모델로 같은 타입의 VM 을 하나 더 만들고 인쇄될 자식만 담는다.
타입이 같으니 템플릿이 그대로 잡히고, 모델이 같으니 배경·테두리·머리글이 똑같이 나온다.
자식 VM 은 새로 만들지 않고 원본을 담아 해석된 글꼴·색을 유지한다.
<b>캔버스가 쓰는 VM 은 한 글자도 건드리지 않는다</b> — 단정 하나가 그것을 지킨다.
모르는 컨테이너 타입은 투영하지 않고 원본을 그린다(잘못 투영하느니 그 편이 낫다).
중간에 한 번 더 걸렸다: 처음 단정이 2 → 2 를 냈는데 그건 레이아웃을 안 돌려 시각 트리가
아예 없었던 것이다. Measure/Arrange 를 돌리고 나서야 17 → 17 이라는 진짜 실패가 보였다.
또 하나 정정: --db-render P062 는 이 경로를 쓰지 않는다(캔버스 PageView 를 찍는다).
"md5 가 걸린 경로"라고 했던 것은 과했다. 그래도 게이트로 확인했고 md5 는 동일하다.
게이트: 테스트 298/298, --edit-smoke 0실패(신규 2), --dialog-shots 글자 2,649개 FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ModifyDesign 은 운영 테이블에 쓰는데 한 번도 실행해 본 적이 없었다.
저장 경로에는 이 검증이 있고(RunSaveSmoke) 수정 경로에만 없었다 —
"코드는 맞아 보이는데" 상태이고, 이 저장소에서 그건 여러 번 틀렸다.
--db-modify-smoke <서식코드> <리포트>. 단정 8건:
① 활성 SdgKey 가 바뀌지 않는다 — EMR 이 참조하는 키 유지가 이 동작의 존재 이유다
② 활성 행이 새 디자인으로 갱신된다 (문구를 실제로 바꿔서 본다 —
무변경 재저장으로는 "갱신됐다"를 증명할 수 없다)
③ 이력 행이 하나 생기고, SdgDelYon='Y' 이고, <b>옛</b> XML 을 담는다
(저장과 방향이 반대다. 뒤집혔으면 ③-c 가 잡는다)
④ 감사 컬럼이 활성 행에 기록된다 — 레거시는 이걸 이력 행에 썼다(버그, 미복제)
⑤ E_SctMst 를 건드리지 않는다
+ 원복 2건
S999 실측: SdgKey 52451 유지, 버전 511 → 512 → 511, 감사 202608120855 → 202608180917.
<b>DB 를 원상태로 되돌린다.</b> 활성 행의 XML·감사값을 원문으로 UPDATE 하고
이 진단이 만든 이력 행을 지운다. DELETE 에 SdgDelYon='Y' 를 함께 걸어
<b>SdgKey 를 잘못 짚어도 활성 행은 어떤 경우에도 지워지지 않게</b> 했다.
<b>⑤ 가 공허하게 통과하고 있었다.</b> S999·P062 둘 다 E_SctMst 가 0건이라
"0 → 0" 을 비교했는데 PASS 로 찍혔다. 읽는 사람은 검증됐다고 믿는다.
행이 0건이면 SKIP 으로 적고 "행이 있는 서식으로 다시 돌려야 한다"를 남긴다.
이번 세션에만 같은 함정에 네 번 걸렸다 — 대조군 없는 0 은 증거가 아니다.
<b>남은 것</b>: ⑤ 를 실제로 재려면 E_SctMst 행이 있는 서식이 필요하다.
테스트 접속에서 아직 못 찾았다.
게이트: --db-modify-smoke 8/8(⑤ SKIP), 테스트 298/298, --edit-smoke 0실패,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
레거시 TK_MODIFY('기록지 수정'). SheetMe 에는 저장만 있었고 이것이 없었다.
DbSmoke.cs:1072 가 그 부재를 이미 적어 뒀다 — 실측 ShtCneYon='Y' 40건, 현역 19건.
<b>먼저 내 앞선 설명을 고친다.</b> 나는 수정이 ShtCneYon 제자리 갱신 경로라고 했는데 틀렸다.
수정 경로는 ShtCneYon 을 <b>읽지도 않는다</b>. 그 컬럼은 저장이 버저닝이냐 제자리냐만 가른다.
저장과 수정은 이력 방향이 반대다.
저장: 옛 행을 SdgDelYon='Y' 로 내리고 <b>새 행이 활성</b>이 된다(SdgKey 바뀜)
수정: <b>활성 행을 제자리에서 갱신</b>하고 옛 XML 을 담은 새 행이 이력이 된다(SdgKey 유지)
SdgKey 가 유지되므로 EMR 이 참조하던 키가 그대로다 — 그게 이 동작의 존재 이유다.
E_SctMst 를 건드리지 않는다(레거시 :170-174 주석 처리). 그것이 "글자만 수정 가능"이라는
경고문의 기술적 근거다 — 기존 매핑 행은 SdgKey 가 그대로라 유효하지만 새 컨트롤은 행이 안 생긴다.
<b>레거시 버그 둘은 복제하지 않는다.</b>
① 감사 컬럼이 뒤집혀 있었다 — 활성 행에는 아무것도 안 쓰고 <b>옛 디자인을 담은 이력 행</b>이
현재 사용자·시각을 받았다(:162-163). 이력 패널이 "누가 언제 이 버전을 만들었는가"를 거꾸로 보여 준다.
활성 행에 기록하고 이력 행은 원래 값을 지킨다.
② Rows(0) 을 개수 검사보다 먼저 읽어 활성 행이 없으면 IndexOutOfRange 였다(:150).
먼저 확인하고 "수정 대신 저장을 쓰세요"로 안내한다.
<b>새 컨트롤은 막는다.</b> 레거시는 산문으로만 경고하고 막지 않았다 —
경고문을 읽지 않으면 그대로 번진다. SctMstXmlWalker 로 문서의 컨트롤 이름을 뽑아
활성 SdgKey 의 E_SctMst 행과 대조한다. 판정 근거를 저장이 행을 만드는 규칙과 같은 것으로 통일해서,
"행이 원래 안 생기는 종류"를 새 컨트롤로 오인하지 않는다.
<b>권한 게이트는 유지한다.</b> 레거시가 이 버튼만 전산실에 묶어 둔 이유가 위와 같다
(frmSheetDesigner.vb:1029-1060, 주석 '전산실만 사용가능'). 저장 버튼에는 그런 분기가 없다.
규칙을 Core 의 순수 함수로 옮겨 표로 고정했다 — 병원·부서 조합이 세 갈래라
화면에서 즉석 판정하면 어느 갈래가 왜 막혔는지 확인할 방법이 없다.
막힌 이유도 갈래마다 다르게 말한다("권한 없음"만으로는 누구에게 물어야 할지 모른다).
<b>HspStrDte 는 세션에 없다.</b> 레거시는 그 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
빈 문자열로 두면 그 갈래가 안 걸리고 부서 규칙(EDPS)으로 떨어진다 — 레거시의 다수 경로와 같다.
없는 값을 지어내 더 조이지 않고, 이 사실을 코드와 테스트에 적어 뒀다.
게이트: 테스트 298/298(신규 7), --edit-smoke 0실패, --dialog-shots FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
<b>실DB 수정 경로는 아직 안 돌려 봤다</b> — 운영 테이블에 쓰는 동작이라 --db-* 진단으로
왕복 검증을 붙이는 것이 다음 일이다.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
<b>먼저 밝힐 것: 내가 "완전 중복"이라고 인용한 판정이 틀렸다.</b>
좌측 목록과 이 대화상자는 같은 조회를 쓰지만(둘 다 ListSheets, WHERE·ORDER BY·상한 동일)
같은 쓸모를 주지 않았다. 지우기 전에 구멍 넷을 좌측에 옮겼다.
<b>① 저장·등록 뒤 재조회 — 가장 컸다.</b>
좌측 목록은 기동 1회와 검색 버튼으로만 채워졌고 저장 뒤 갱신이 없었다.
즉 <b>방금 저장한 서식이 목록에 안 떴다</b>. 대화상자는 열 때마다 재조회해서 그 구멍을 덮고 있었다.
그것을 안 메우고 창만 지웠으면, DB 전용으로 바꾼 직후 신규 서식을 저장한 사용자가
그 서식을 다시 찾지 못했을 것이다.
<b>② 서식명 툴팁</b> — 좌측은 폭이 148px 남짓(자물쇠 붙으면 134, 패널 최소폭이면 80)이라
긴 이름이 잘리는데 전문을 볼 방법이 없었다. 대화상자는 열이 300px 에 드래그로 늘릴 수 있었다.
<b>③ '디자인 없음'과 '서식생성기 사용 꺼짐'</b> 이 좌측에서는 둘 다 같은 회색+Opacity 0.6 이라
화면만 보고 구분할 수 없었다. 툴팁으로 갈랐다.
<b>④ Enter 로 열기</b> — 좌측은 더블클릭 전용이라 키보드로 서식을 열 수 없었다.
대화상자에는 [열기] 버튼이 있어 그 구멍이 가려져 있었다.
검색칸에서 ↓ 로 목록에 내려가는 길도 붙였다.
그 다음 지웠다: SheetOpenDialogView(680×560), OpenFromDbCommand, 파일 메뉴의 파일 열기·
다른 이름으로 저장·DB에서 열기, 빈 화면의 '파일 열기' 버튼, Ctrl+Shift+S.
Ctrl+O 는 이제 서식 목록으로 간다 — 전에는 OS 파일 대화상자였고, 운영 서식은 전부 DB 에 있다.
<b>SaveMode 기본값은 바꾸지 않았다.</b> 조사해 보니 그 규칙은 실재하고 문서·코드 4곳에 있다 —
docs/DEPLOYMENT.md:56 은 "이 기본값을 바꾸지 말 것"이라고 명시한다.
배포된 ini 의 CurrentServer 가 운영 병원 DB 를 가리켜서, 명시적으로 켠 단말에서만
운영 테이블(E_SdgMst/E_SctMst)에 쓰게 하려는 장치다. ini 는 SaveMode 를 담지 않으므로
이 규칙이 병원 단말의 유일한 방어선이다. UI 진입점만 없애고 규칙은 그대로 둔다.
파일 저장·열기 <b>코드</b>도 남긴다 — 접속 없이 도는 --render-smoke 와 --edit-smoke 직렬화 왕복이
그 경로를 쓴다.
대신 화면이 상태를 말하게 했다 — SaveMode=File 단말에서는 저장 메뉴·버튼이 비활성이다.
전에는 항상 활성이고 누른 뒤에야 경고가 떴다.
<b>종료 가드를 고쳤다 — 앱이 안 닫히는 상태를 없앤다.</b>
전에는 저장 실패가 곧 종료 중단이었다. 파일 폴백이 있을 때는 드문 분기였지만
저장이 DB 로만 가면 흔한 경로가 된다 — SaveMode=File 단말이나 접속 불가에서
<b>수정한 탭을 가진 사용자가 앱을 닫을 수 없게</b> 된다. 이제 '버리고 종료'를 묻는다.
게이트 대체물도 함께 옮겼다: --dialog-shots 의 01-sheet-open 을 지우면
서식명 잘림을 잡을 자리가 통째로 사라지므로, 좌측 목록에 표본을 넣은 01-sheet-list 샷으로 바꿨다
(진단 모드는 Loaded 를 안 돌려 목록이 비므로 표본을 직접 주입한다).
검사한 글자 2,531 → 2,649개.
게이트: 테스트 291/291, --edit-smoke 0실패, --dialog-shots 62장 FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
여기까지 간격을 정하는 길은 4px 씩 넓게/좁게뿐이었다.
20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 12왕복이고 매번 마우스가 좌상단까지 갔다.
단축키(Alt+[ / Alt+])로 왕복은 없앴지만 여전히 4px 씩이다.
드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver),
<b>명령 경로에는 그 값을 넣을 칸이 없었다</b>. 인스펙터에 세로·가로 칸을 붙인다.
<b>둘</b>부터 뜬다 — 라벨과 입력칸 한 쌍이 가장 흔한 경우다.
균등 나누기가 셋을 요구하는 것과 기준이 다르므로 표시 조건을 따로 뒀다(CanSpaceExactly).
<b>첫 항목은 움직이지 않는다.</b> 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다 —
"24 를 넣었는데 블록이 아래로 기어간다"가 되고, 그건 사용자가 자기 조작을 의심하게 만든다.
검사에서 8 을 두 번 넣어 첫 항목의 Y 가 그대로인지 본다.
<b>값이 갈리면 빈 칸이다.</b> 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽혀서,
사용자는 이미 고른 상태라고 믿고 넘어간다.
음수·NaN 은 무시한다 — 조용히 뒤엉킨 배치가 되는 것보다 아무 일도 안 하는 쪽이 낫다.
실측 6건: 갈린 값 빈 칸 · 24 로 놓으면 24 · 첫 항목 고정 · 재입력 시 안 밀림 · 음수 무시 · NaN 무시.
게이트: 테스트 291/291, --edit-smoke 0실패(신규 6), --dialog-shots 글자 2,531개 FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4단계 계속.
우측 300px 정보 패널 6블록 중 <b>4개가 왼쪽 목록 행의 재진술</b>이었다.
이름 — 목록 행 · 하단 선택 칩 · 이 패널 <b>세 곳에 동시에</b>
분류 — 좌측 분류 열의 선택 강조와 중복
사용 건수 — 목록 행 배지와 중복
추천 — 분류('자주 쓰는 것') · 목록 정렬 1순위 · 인스펙터 칩에 이어 <b>네 번째</b>
300px 세로 전체를 쓰면서 새 정보는 두 블록뿐이었다.
남긴 둘은 다른 어디에도 없다 —
'이 태그의 값'(값이 나오면 값, 아니면 왜 안 나오는지)과
'변형'(같은 값을 다른 형식으로 내는 태그).
복사 버튼은 값 블록 머리로 옮겼다 — 태그는 이름이 곧 키라 복사는 계속 쓸모가 있다.
우측 칸 300 → 230, 목록 행 높이 26 → 20.
행 총높이는 38 → 32 다(24 로 가려면 ListBoxItem 패딩을 건드려야 하는데
그 스타일은 서식 목록·레이어와 공용이라 이번에는 두었다).
가용 487px 기준 12.8행 → 15.2행.
<b>주석을 지우다 XAML 을 깨뜨렸고 게이트가 잡았다.</b>
옛 주석의 본문이 닫는 태그만 남은 채 <b>본문 텍스트로</b> 들어가
UIElementCollection 에 문자열을 넣는 XamlParseException 이 됐다.
--dialog-shots 가 태그 피커 두 장을 FAIL 로 표시했다 —
그 게이트가 없으면 태그를 고르러 창을 열 때까지 몰랐을 것이다.
(같은 실행에서 글자 검사 수가 2,465 → 2,305 로 떨어졌는데, 그건 창 두 개가 아예 안 떠서였다.
고친 뒤 2,465 로 돌아왔다 — 지운 블록은 접힌 상태라 애초에 세지지 않았다.)
게이트: 테스트 291/291, --edit-smoke 0실패, --dialog-shots 글자 2,465개 넘침 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4단계 계속. 주 경로에서 1080x700 모달이 사라진다.
칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
나머지는 이름을 정확히 16자 외워 치거나 모달을 열어야 했고, 자유 입력은 열려 있는데
제안이 없어서 <b>오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다</b>.
같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있었다 —
패턴은 있는데 운영 8,032건이 걸린 칸에만 없었다.
<b>새 패턴을 발명하지 않았다.</b> QueryEditorWindow 의 CompletionPopup 에서 배운 것을 그대로 쓴다:
StaysOpen=False 로 두면 팝업이 마우스를 캡처해 항목 클릭을 '바깥 클릭'으로 먹어 버린다
(눌러도 입력이 안 되고 사라진다 — 그 함정을 이미 한 번 겪었다).
AllowsTransparency 도 켜지 않는다 — 레이어드 창이 되면 ClearType 이 꺼져 13px 한글이 뭉개진다
(진단 cleartype 의 D 표본). 대신 안쪽에 ClearTypeHint 를 건다.
<b>칩이 사라지는 기준을 바꿨다.</b> 전에는 ValueText.Length == 0 이라 값이 정해지면 칩이 사라졌다.
그래서 잘못 고른 태그를 바꿀 때 도움이 끊겼다 — 태그를 고치는 일은 처음 고르는 일만큼 잦은데
고치는 쪽만 맨손이었다. 이제 기준은 '값이 있는지'가 아니라 <b>'타이핑 중인지'</b>다.
타이핑을 시작하면 아래 완성 목록이 같은 일을 더 정확하게 하므로 칩을 접는다.
그 옛 규칙을 고정하던 검사 하나를 새 규칙으로 고쳤다 — 의도한 변경이다.
완성 목록은 입력 중인 글자를 <b>인자로</b> 받는다. 태그 행의 Text 바인딩이
UpdateSourceTrigger=LostFocus 라 타이핑 중에는 ValueText 가 아직 옛 값이고,
그것으로 목록을 만들면 한 글자 뒤처진다.
순위는 이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름이다.
알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
검색은 TagSearch 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다('환자명'으로도 뜬다).
<b>Popup.IsOpen 은 기본이 TwoWay 다.</b> 읽기 전용 속성에 바인딩하면 XamlParseException 이고,
그 실패가 --dialog-shots 를 통째로 죽였다(종료코드 3). Mode=OneWay 를 명시한다.
게이트가 아니면 인스펙터를 띄우는 화면에서만 터졌을 것이다.
실측 14건: 값이 있어도 칩 유지 · 타이핑 전 미표시 · 타이핑 시 칩 접힘 · 별칭 적용 ·
자주 쓰는 것이 위 · 10줄 상한 · ↑↓ 순환 · Enter 확정과 커밋 · 확정 후 닫힘/칩 복귀 ·
정확히 하나면 안 가림 · Esc 가 값을 보존.
게이트: 테스트 291/291, --edit-smoke 0실패(신규 14), --dialog-shots 글자 2,465개 넘침 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4단계 시작.
사용자가 태그를 못 찾은 이유는 접두어(PAT_·ETC_)가 아니라 <b>어휘</b>였다.
검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다.
실제로 세어 보니 흔한 말이 전부 0건이었다 —
환자명 0건 (표기는 이름) 날짜 0건 (일자, 21건)
사인 0건 (싸인, 17건) 주민등록번호 0건 (주민번호)
오늘 0건 (현재, 11건) 도장·신장·남녀·성명 0건
서명 태그가 전부 레거시 오표기 '싸인' 이라, 의료진이 '사인'을 치면
"결과 없음"만 나오고 회복 경로가 없었다.
입력어를 카탈로그 표기로 옮기는 표 34항을 넣었다.
<b>태그마다 설명을 지어내는 것이 아니다</b> — 오른쪽 값은 전부 카탈로그에 실재하는 표기다.
없는 내용을 만들지 않는다는 규칙은 그대로다.
<b>표만 검사하면 부족하다.</b> "환자명 → 이름" 매핑이 옳다는 것만 확인되고
그 표기가 카탈로그에 실재하는지는 확인되지 않는다 — 없는 표기로 옮기면 여전히 0건이다.
그래서 실제 카탈로그 384종을 대상으로 건수를 단정하고,
별칭이 가리키는 표기가 카탈로그에 있는지도 따로 단정한다(그 검사가 없으면
"오늘 → 없는말" 같은 표를 적어도 0건인 채로 통과한다).
<b>테스트 가정을 한 번 틀렸다.</b> "'사인' 과 '싸인' 이 같은 건수"라고 적었는데
17 대 16 으로 깨졌다 — 별칭은 대칭이 아니다.
'싸인' 질의는 별칭 '서명' 까지 타서 한 건을 더 잡는다.
재야 할 것은 "'싸인' 이라고 적힌 태그를 '사인' 으로 다 찾는가" 이므로 원문으로 세도록 고쳤다.
화면에는 별칭으로 찾아 줬다는 사실을 적는다 — "17개 — 이 카탈로그는 '싸인' 으로 적혀 있습니다".
결과만 내놓으면 사용자는 다음에도 자기 말이 통했다고 오해하고,
결과가 비었을 때 왜 비었는지도 모른다.
게이트: 테스트 291/291(신규 6), --edit-smoke 0실패, --dialog-shots 넘침 0,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3단계 계속.
<b>① 선택이 바뀌면 편집하던 칸과 스크롤이 사라졌다.</b>
선택 집합이 바뀔 때마다 Rows.Clear() 로 행을 통째로 다시 만들기 때문이다.
하루 수백 건을 다루는 사람에게 그건 "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
행을 키 기준 갱신으로 바꾸는 쪽이 더 근본적이지만 <b>이번에는 하지 않는다</b>.
이 패널은 회귀 기록이 가장 많은 곳이고(MainView.xaml:321-322, LayerPanelView.xaml:127-129),
실제 아픔은 재구성 비용이 아니라 포커스·스크롤 소실이다
(--scale-budget 이 뷰모델 작업은 마이크로초 단위임을 보여 준다).
그래서 재구성은 그대로 두고 포커스와 스크롤만 되돌린다. 행은 라벨로 찾는다 —
타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
Rebuild 를 감쌌다. 본문에 이른 return 이 여러 갈래 있어서 끝에 이벤트를 두면
도는 경로와 안 도는 경로가 갈리고, 그러면 <b>어떤 선택 변경에서만</b> 포커스가 안 돌아온다 —
그런 결함은 재현 조건을 찾기 전까지 "가끔 그런다"로만 보인다.
복원은 DispatcherPriority.Loaded 로 미룬다. 즉시 부르면 컨테이너가 아직 없어 빈 트리를 뒤진다
(이 세션에서 같은 함정을 두 번 밟았다).
사용자가 그 사이 다른 칸을 눌렀으면 포커스를 빼앗지 않는다.
<b>② F4 로 인스펙터에 들어간다.</b> 인스펙터로 포커스를 보내는 키가 저장소에 하나도 없었다.
캔버스에서 Tab 을 누르면 좌측 패널·문서 탭·플로팅 바를 다 지나야 인스펙터에 닿았다.
커맨드로 만들지 않는다 — 그러면 뷰모델이 뷰의 포커스를 알아야 한다.
<b>③ 저장 확인 문면을 두 층으로 갈랐다.</b>
이 대화상자는 세 부류가 다 보는 유일한 위험 지점인데(사내 인력·병원 전산팀·의료진),
본문에 E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고, 간호부에게는 공포다.
위층은 사람 말("이 서식의 새 버전을 만듭니다. 지금 쓰이는 버전은 이력으로 남습니다"),
테이블·컬럼은 접힌 상세로 내렸다.
게이트: 테스트 285/285, --edit-smoke 0실패, --dialog-shots 글자 2,465개 검사 넘침 0,
--modal-check 0실패, --scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3단계 계속.
<b>① Ctrl+S 가 DB 문서를 디스크에 쓰고 있었다.</b>
Ctrl+S 는 SaveFileCommand 에 걸려 있고 그건 항상 파일 저장이다.
DB 에서 연 서식에서 누르면 OS 저장 대화상자가 뜨고 XML 이 디스크에 쓰였다 —
사용자는 저장했다고 믿지만 <b>DB 에는 아무것도 안 들어간다</b>.
종료 가드는 이미 IsFromDb 로 갈라 준다(MainViewModel.cs:365). Ctrl+S 만 안 갈라 주고 있었다.
<b>② 저장 완료 알림 모달을 없앤다.</b> 정보량이 0 이었다 —
SdgKey 를 바로 위에서 상태바에 넣고 같은 값을 모달로 한 번 더 보여 주는 것이었다.
하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고, 그때마다 화면이 어두워졌다.
실패는 계속 모달로 알린다 — 조용히 넘기면 저장된 줄 알고 넘어간다.
<b>③ 스크림의 기본을 뒤집었다.</b> 전에는 모든 ShowDialog 에 무조건 붙어서
태그 하나 고르는 데도 앱이 암전했다. 하루 수백 번이면 신호가 아니라 소음이다.
전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고
그건 눈으로 전수 확인해야만 안다. 대신 창이 스스로 자격을 밝혀야 깔리게 했다
(ModalScrim.DimBehind). 새 대화상자가 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다.
자격을 밝힌 것: 물음(되돌릴 수 없는 쓰기의 확인)·오류(진행이 정의되지 않는 차단)·서식 신규 등록.
--modal-check 에 ⑤ 를 더했다: <b>자격을 밝히지 않은 창은 어두워지지 않는다</b>.
①~④ 만으로는 "깔린다"만 확인되고 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다.
<b>④ 배치 명령에 단축키와 우클릭 메뉴를 붙였다.</b>
전에는 단축키가 0개였고 우클릭 메뉴가 앱 전체에 하나도 없었다.
정렬 6종·같은 크기·간격이 전부 로고 아래 4단 메뉴 안에만 있어서,
20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 화면 좌상단까지 갔다.
자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
균등 Alt+Shift+H/V, 같은 크기 Alt+Shift+W/S, 간격 Alt+[ / Alt+].
<b>Alt 조합에 함정이 있다.</b> WPF 는 Alt+문자를 Key.System 으로 싸서 넘기고
실제 키는 SystemKey 에 있다. e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데,
그 실패는 "눌러도 아무 일이 없다"로만 보여서 원인을 찾기 어렵다.
우클릭 메뉴에 InputGestureText 를 적었다 — 단축키를 모르는 사람이 거기서 배운다.
게이트: 테스트 285/285, --edit-smoke 0실패, --modal-check 0실패(신규 1),
--dialog-shots 넘침 0(대조군 4/4), --scale-budget 5/5, --maxrect 0실패,
--cleartype 11/11, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
이 도구의 일은 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"의 반복인데,
<b>그 루프가 마우스 없이는 한 바퀴도 돌지 않았다.</b> 선택을 옮길 키가 하나도 없었다 —
캔버스 키맵 18개 중 선택 이동 0, 편집 시작 0. 화살표는 선택이 아니라 컨트롤을 민다.
Label 59,997 + TextBox 28,574 의 문구를 고치는 일이 전부 더블클릭에 묶여 있었다.
<b>읽는 순서로 돈다 — z-order 가 아니다.</b> 계획서는 z-order 순회를 적었지만,
Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다.
기록지는 위→아래·왼쪽→오른쪽으로 읽으므로 그 순서가 손에 맞는다.
z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다.
같은 줄로 볼 만큼 가까우면(8px = 그리드 두 칸) X 로 가른다.
EMR 런타임 입력 순서인 ApplyTabOrder 와는 다른 개념이다 — 그건 저장되는 값이다.
<b>범위는 형제다.</b> 컨테이너 안으로는 Enter, 밖으로는 Esc.
전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다.
Esc 가 이제 한 겹만 나간다. 전에는 항상 선택을 통째로 풀어서,
패널 자식을 고른 뒤 Esc 를 누르면 부모로 돌아가는 대신 선택이 사라졌다.
드래그 중 Esc 는 그대로 드래그 취소다.
선택이 옮겨지면 RevealControl 로 화면에 데려온다 —
레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다.
PreviewKeyDown 에 붙인다. KeyDown 이면 WPF 포커스 관리자가 Tab 을 먼저 먹어
포커스가 캔버스 밖(좌측 패널·문서 탭·플로팅 바)으로 나간다.
검사는 배선이 아니라 <b>한 바퀴가 도는지</b>를 본다 — 9건:
읽는 순서 · 끝에서 처음으로 되돌아옴 · 역방향 · 컨테이너 안 · 밖 · 나갈 곳 없음 · 편집 시작.
컨테이너는 GroupSelection 이 아니라 문서에 직접 만든다 —
그 경로에는 거절 조건(라디오·z 불연속)이 있어 막히면 순회 대신 그룹 규칙을 재게 된다
(실제로 처음에 그렇게 막혔다).
게이트: 테스트 285/285, --edit-smoke 0실패(신규 9), --dialog-shots 넘침 0(대조군 4/4),
--scale-budget 5/5, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
3단계 시작 — 실사용 버그 두 건.
<b>① 미리보기·인쇄가 인쇄 필터를 무시하고 있었다.</b>
레거시 런타임은 인쇄에서 이렇게 걸러낸다 —
If Me.Visible = False OrElse mbPrintOutPut = False Then Return False
그 문장이 ControlRegistry.cs:17 에 인용까지 되어 있는데, 렌더 경로가 두 키를 아무도 읽지 않았다.
대신 걸러낸 것은 ControlElement.Hidden 뿐이고 그건 [JsonIgnore] 디자이너 전용 플래그다
(주석에도 "레거시 Visible 과 별개"라고 적혀 있다).
그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
서식을 저장하기 전에 결과를 확인할 유일한 수단이 거짓말을 하고 있었다.
<b>키 이름이 타입마다 다르다.</b> 라벨만 소문자 visible 이다
(Label.vb 가 Shadows Property visible 로 Control.Visible 을 가리고 직렬화기가 그림자를 쓴다).
운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다.
그래서 관용으로 둘 다 읽지 않는다 — 타입이 선언한 키를 카탈로그에서 찾아 그것만 읽는다.
둘 다 읽으면 대문자 Visible=False 인 라벨이 우리 인쇄에서만 빠지고, 그건 호환이 아니라 새 차이다.
모르는 값과 없는 키는 인쇄한다 — 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다.
캔버스는 그대로 다 보여 준다. 편집 중인 것을 못 보면 고칠 수 없다.
--db-render 는 이 경로를 쓰지 않아 종이 렌더 md5 는 불변이다(확인했다).
<b>② 팔레트 즉시 배치가 9번째부터 겹쳤다.</b>
(page.Controls.Count % 8) * 16 이라 두 가지가 틀렸다 —
9번째가 1번째와 정확히 같은 자리에 떨어지고, 하나 지우고 다시 놓으면 지운 자리로 갔다.
단조 증가 카운터로 바꿨다. 계단은 종이를 벗어나기 전에 옆으로 비킨다.
<b>순수 함수 테스트만으로는 배선을 증명하지 못한다.</b>
PrintFilter 단위 테스트 8건은 판정이 옳다는 것만 말한다. 렌더 경로가 그 판정을 부르는지는
다른 문제이고, 이 앱에서 "코드는 맞아 보이는데 화면은 다른" 일이 이번 세션에만 세 번 있었다.
그래서 --edit-smoke 에서 BuildPageVisual 의 자식 수를 실제로 세는 검사 3건을 더했다.
게이트: 테스트 285/285(신규 8), --edit-smoke 0실패(신규 5),
--dialog-shots 넘침 0(대조군 4/4), --scale-budget 5/5, --maxrect 0실패,
--cleartype 11/11, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
재설계 1단계 마지막. --scale-budget [출력] [컨트롤수], 기본 1067.
--edit-smoke 는 컨트롤 3개 합성 문서에서 배선만 본다. 운영 최대는 1,067개인데
그 규모의 숫자가 CI 에 하나도 없었다. 없으면 재설계가 새 상호작용을 얹었을 때
재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다.
실측(최상위 1,067 + 중첩 자식 533, 마퀴로 1,067개 전부 잡힘):
호버(선택 0) 평균 0.01ms 최악 0.02ms
드래그(선택 1) 평균 1.41ms 최악 7.49ms
드래그(선택 500) 평균 2.70ms 최악 9.73ms
마퀴 전체 선택 7.29ms
되돌리기 1회 7.74ms
예산: 프레임당 16ms(60fps), 한 번 하는 조작 1000ms. 전부 통과.
<b>이 숫자가 재설계 계획의 2단계를 무효로 만든다.</b>
계획은 "좌표 캐시"를 3단계(상호작용) 앞에 필수로 두었고 근거는
"PageOf 가 O(N) 이고 O(N) 루프 안에 있다 — 드래그 프레임당 약 107만 회 참조 비교,
마퀴 전체 선택 한 번에 5~6백만 회"였다.
연산 횟수 산술은 맞다. 틀린 것은 그 횟수에 <b>연산당 비용을 곱하지 않은 것</b>이다 —
참조 비교는 1ns 수준이라 5백만 회가 곧 5ms 이고, 실제로 마퀴가 7.29ms 로 그 예측과 맞는다.
즉 분석은 옳았고 결론("느리다")만 틀렸다.
좌표 캐시는 지금 근거가 없으므로 뺀다. 필요해지면 이 게이트가 먼저 알려 준다.
<b>규모를 두 번 틀리게 잡았다.</b> 처음엔 자식 절반을 최상위에서 빼서 N=534 를 재고 있었다.
O(N) 원시연산이 보는 N 은 최상위 개수라 그 상태로는 최악을 못 잰다.
'만들려던 수와 다르면 실패'라는 가드가 그것을 잡았다.
재지 못하는 것을 클래스 주석에 적었다 — 여기 도는 것은 뷰모델 계층뿐이고
WPF Measure/Arrange·바인딩 재평가·렌더가 빠져 있다.
줌이 LayoutTransform 이라 눈금마다 서브트리를 다시 재는 비용,
Undo 뒤 시각 트리를 다시 세우는 비용은 이 숫자에 없다.
게이트: --scale-budget 5/5, 테스트 277/277, --dialog-shots 넘침 0(대조군 4/4),
--edit-smoke 0실패, --maxrect 0실패, --cleartype 11/11, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
재설계 1단계 계속.
<b>메인 셸이 게이트에 아예 없었다.</b> 사용자가 하루 종일 보는 화면만 자동 검증 밖이었다.
넣고 나서 글자 31개만 검사되는 것을 보고 빈 껍데기를 찍고 있다는 것을 알았다 —
컨트롤을 놓고 하나를 골라 인스펙터·레이어를 채우니 81~95개가 된다.
세 크기·탭으로 찍는다: 기본 1440x920, 현장 최대화 1920x1032, 도구 상자 탭.
Loaded 커맨드(서식 목록 DB 조회)는 진단 모드에서 돌지 않게 막았다 —
게이트 결과가 접속 상태에 따라 달라지면 안 된다.
<b>게이트가 게이트의 오류를 잡았다.</b> 메인 화면을 넣자 세로잘림 30건이 떴는데 전부 오탐이었다.
DesiredSize 는 Margin 을 포함하고 RenderSize 는 포함하지 않아서, 세로 Margin 이 있는
TextBlock 이 예외 없이 걸린 것이다(도구 상자 그룹 머리가 "필요 27 > 실제 14" 였고 그 13 이 Margin).
Margin 을 빼고 비교하도록 고쳤다. 이 검사기에서 측정이 나를 정정한 세 번째다.
<b>--maxrect 가 최소 크기를 베껴 두고 있었다.</b> ProbeMinWidth=940 / ProbeMinHeight=640 /
ProbeBorder=6 은 MainView.xaml 의 복사본이라, 최소 크기를 바꾸면 이 검사가
<b>옛 값을 계속 검사하며 계속 통과한다</b>. 실제 MainView 를 만들어 XAML 이 정한 값을 읽는다.
<b>창 크기·위치·상태·두 패널 폭을 기억한다.</b> UserPrefs 가 Dictionary<string,bool> 이라
숫자를 못 담던 것이 원인이었다. JsonElement 로 바꿔 이미 깔린 prefs.json 이 살아남게 했다 —
string 이나 double 로 바꾸면 역직렬화가 터져 기존 취향이 통째로 초기화된다.
복원값은 반드시 클램프한다. 이 앱은 WindowStyle=None 이라 제목 표시줄을 직접 그리므로,
저장된 위치가 지금 화면에 없으면(회사에서 두 번째 모니터, 집에서 노트북) 메뉴·저장·닫기가
전부 손에 닿지 않고 마우스로 되돌릴 방법이 없다.
기본 크기로는 이 문제가 안 나지만, 저장을 시작하는 순간 복원되는 것은 사용자가 만든 값이다.
닫기를 물렀을 때는 저장하지 않는다 — 조작하지 않은 값이 남는다.
게이트: 테스트 277/277, --dialog-shots 대조군 4/4 · 글자 2,465개 검사 · 넘침 0건,
--maxrect 0실패(MainView 실측 940x640 테두리 6), --cleartype 11/11,
--edit-smoke 0실패, --modal-check 0실패, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
재설계 1단계. 화면 픽셀은 하나도 안 옮긴다.
지금까지 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없었다.
테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
--dialog-shots 는 PNG 56장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
그래서 이번 세션에만 세 건이 눈으로만 잡혔다 — "232 로는 이름이 잘린다",
"220 이면 마지막 토글이 잘린다", "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다".
글자를 담은 요소만 본다. WPF 에서 DesiredSize > RenderSize 는 정상인 경우가 많아
전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.
측정이 검사 방식을 두 번 정정했다.
① "Width 를 좁게 주고 말줄임표를 끄면 잘린다"고 놓았는데 아니었다.
TextTrimming=None 이면 TextBlock 은 준 Width 를 지키지 않고 자연폭을 그대로
ActualWidth 로 보고한다(Width=40 인데 ActualWidth=213.3).
즉 WPF 에서 말줄임표 없는 NoWrap 글자는 잘리는 것이 아니라 <b>옆을 침범한다</b>.
그래서 자기 ActualWidth 와 비교하는 대신 <b>자르는 조상의 사각형</b>과 비교한다.
자르는 조상이 없으면 위반으로 세지 않는다 — 겹침은 다른 종류의 결함이고
이 검사기가 판정할 수 있는 것이 아니다.
② 0 폭 칸에 갇힌 글자는 ActualWidth 가 0 이 아니라 9.6 이었다(말줄임표 글리프 몫).
대조군 기대값을 그에 맞게 고쳤다.
자가 점검을 함께 넣었다. 위반 0건은 "화면이 깨끗하다"와 "검사기가 고장났다"를
구분해 주지 않고, 보고서에서 그 둘은 똑같이 생긴다. 이번 세션에 그 함정에 두 번 빠졌다 —
--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
--modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다.
그래서 일부러 잘린 대조군 4종을 넣고, 검사한 글자 수를 함께 적는다.
실측: 대조군 4/4 · 창 57개에서 글자 1,936개 검사 · 넘침 0건.
기동 화면 밖 문제는 최우선에서 내렸다 — 현장 단말이 1920x1080 이상뿐이라는 답을 받았다.
1440x920 을 1920x1032 에 CenterScreen 하면 Left 240 / Top 56 으로 양수다.
MaximizeToWorkArea.cs:251 의 1366x768@125% 주석은 현장 기술이 아니라 방어적 가정이었다.
같은 이유로 A4 맞춤은 50% 가 아니라 77% 이고, 화면 예산 회수는 급한 일이 아니다.
게이트: 테스트 277/277, --dialog-shots 대조군 4/4 넘침 0, --cleartype 11/11,
--edit-smoke 0실패, --modal-check 0실패, --maxrect 0실패,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
사용자 지적이 맞았다. Effect 가 걸린 요소는 하위 트리가 중간 서피스로 렌더링되면서
ClearType 이 꺼지고, 글자가 회색 안티에일리어싱으로 그려진다. 13px 한글이 특히 뭉개진다.
저장소는 이 사실을 이미 알고 있었다 — MessageDialogView.xaml:18 과 TagPickerDialogView.xaml:11 이
"AllowsTransparency 를 켜면 ClearType 이 꺼져 13px 한글이 뭉개진다"고 적어 두었다.
그런데 그 판단이 <b>창에만</b> 적용되고 Effect 와 Popup 에는 적용되지 않았다.
ClearTypeHint 는 저장소 전체에 0 곳이었다.
추론으로 끝내지 않았다. RenderTargetBitmap 으로는 이 질문에 답할 수 없다 —
그 자체가 알파를 가진 중간 서피스라서 Effect 가 있든 없든 양쪽 다 회색으로 나오고,
검사가 통과하면서 아무것도 증명하지 못한다. 그래서 진짜 화면을 BitBlt 으로 떠서
글리프의 채널 어긋난 픽셀(ClearType 은 R≠G≠B, 회색 AA 는 R=G=B)을 셌다.
--cleartype 표본 11종 실측:
A 아무것도 없음 색번짐 1115 ← 대조군. 이게 0 이면 판정 불가로 끝낸다
B Effect 색번짐 0 ← 지적한 그대로
C1 힌트를 Effect 요소 자신에게 색번짐 0 ← 듣지 않는다
C2 힌트를 안쪽 불투명 배경에 색번짐 1344 ← 여기가 맞는 자리
D 레이어드 팝업 색번짐 0 ← 두 번째 원인
E 팝업 + 힌트 색번짐 1115 ← AllowsTransparency 를 끌 필요가 없다
I 힌트 바깥에 ScrollViewer 색번짐 0 ← ScrollViewer 도 중간 서피스를 연다
J 힌트를 ScrollViewer 안쪽에 색번짐 1115
F 앱 콤보 드롭다운(실제 템플릿) 색번짐 0 → 수리 후 1115
두 번 헛짚었고 두 번 다 측정이 정정했다.
CornerRadius 를 의심했으나 아니었고, ScrollViewer 가 범인이었다.
그리고 F 를 처음 통과시켰을 때는 선택된 콤보 항목의 파란 배경(B.Sel, 채널 차이 61)이
색번짐으로 잡힌 것이었다 — 색번짐 4359 인데 글자픽셀은 1075 였다.
그래서 배경이 유채색이면 판정을 거부하는 장치를 넣었다.
규칙: 힌트는 Effect·ScrollViewer·레이어드 팝업 <b>안쪽</b>의 불투명 배경 요소에 건다.
적용한 곳 — 글자가 많은 순:
콤보 드롭다운(DesignerTheme) · 메뉴 하위팝업(배치·정렬 명령이 다 여기 있다) ·
색 팝오버 · 컨트롤 추가 플라이아웃 · 줌 플라이아웃
남긴 곳: 플로팅 바(글자는 줌 퍼센트뿐), QueryEditorWindow 의 Card 스타일
(Effect 가 DataGrid·편집기의 중첩 ScrollViewer 를 감싸서 힌트를 넣을 자리가 여럿이다 —
그림자를 빼는 쪽이 맞아 보이고, 그건 디자인 결정이라 따로 다룬다).
게이트: --cleartype 11/11, 테스트 277/277, --edit-smoke 0실패, --modal-check 0실패,
--maxrect 0실패, --dialog-shots 57파일, --db-smoke 1271건 diff 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일(종이 렌더 불변).
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
두 번째 오진이다. 지난번에는 가림막이 아예 안 깔렸고, 고친 뒤에는 <b>대화상자 위에</b> 깔렸다.
그래서 대화상자까지 같이 흐려져 글자가 눌려 보였다.
원인: 가림막과 대화상자가 같은 창을 소유자로 갖는 형제라, 나중에 띄운 가림막이 위에 온다.
Activate() 로는 안 된다 — 활성 창을 바꾸는 것과 z순서를 바꾸는 것은 다른 일이다.
SetWindowPos 로 가림막을 대화상자 바로 아래에 꽂는다.
두 번 다 "코드를 읽어서는 맞아 보이는데 화면은 다른" 경우였다.
그래서 --modal-check 에 z순서 검사를 더했다 — 창 개수만 세면 위에 있는지 아래에 있는지 모른다.
GetWindow(GW_HWNDNEXT) 로 대화상자 다음 창이 가림막인지 본다.
실측: 떠 있는 동안 가림막 1개 · 대화상자 아래에 있음 · 닫으면 0개.
게이트: 테스트 277/277, --edit-smoke 0실패, --modal-check 3/3(1건 신규),
--dialog-shots 57파일, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
앞 커밋의 판정표를 화면에 붙였다. 우측 정보 패널에 '이 태그의 값' 한 칸.
'미리보기'라 부르지 않는다. 실제로 값이 나오는 것은 384종 중 16종뿐이라
그렇게 이름 붙이면 나머지 368종이 고장 난 것처럼 보인다. 여기서 보여 주는 것은
값이거나 사유이고, 사유 쪽이 대부분이며 실은 그쪽이 더 쓸모 있다 —
ETC_요양기관명칭_병원명(실사용 3위)이 왜 여기서 값이 안 나오는지는 지금 어디서도 알 수 없다.
TagPreviewStore 를 만들었다. 왕복 2회로 끝난다 — 서버 시각 1회, M_UIDMST 1행 1회.
세션에 이미 있는 것(사용자 코드·이름) 말고 없는 것은 연락처 두 칸뿐이라 그 줄만 더 읽는다.
재료는 창당 한 번만 읽는다: 태그를 고를 때마다 치면 목록을 화살표로 훑는 동안
초당 몇 번씩 왕복한다.
지킨 것:
· 고정 SQL 만. 사용자 입력이 SQL 에 닿는 경로를 만들지 않는다 — 이 창은 임의 쿼리를 돌리는 곳이 아니다.
· CommandTimeout 5초. 이 저장소에 CommandTimeout 이 0건이었는데 ODP.NET 기본은 무제한이다.
· 실패는 삼킨다. 미리보기가 안 되는 것과 태그를 못 고르는 것은 다른 일이다.
· DB 접속이 없으면 줄 자체를 감춘다 — 진단 스크린샷이 DB 없이 이 창을 띄우므로
이 가드가 없으면 거기서 늘 '값을 읽지 못했습니다'가 찍힌다(--dialog-shots 57파일 그대로).
· 값이 비는 두 갈래를 가른다. '읽지 못했다'와 '이 계정에 값이 없다'는 다른 일이다.
ServerClock 을 internal 에서 public 으로 열었다(다른 변경 없음).
게이트: 테스트 277/277, --edit-smoke 0실패, --modal-check 2/2, --dialog-shots 57파일,
--db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
'예시 값' 을 진짜로 보여 주기로 했는데, 조사해 보니 서식생성기에서 값이 나오는 태그는
384종 중 16종(4%)뿐이다. 나머지는 환자·내원 문맥이 있어야 한다.
그런데 조사에서 값보다 쓸모 있는 것이 나왔다 — <b>왜 안 되는지</b>다.
지금은 어디서도 알 수 없다:
· ETC_요양기관명칭_병원명(실사용 3위, 303건)이 환자 내원을 타고 병원을 푼다는 것
· 값이 아니라 표를 돌려주는 태그 15종(콤보 채우기용이라 한 줄 값이 없다)
· 파일서버 이미지 경로를 돌려주는 태그 9종(싸인·직인)
· 본문이 Return "" 인 죽은 태그 1종
· 태그가 아니라 레거시 내부 함수 8종 — 우리 카탈로그가 Public Function 을 전수 추출해
섞여 들어왔다. 고르면 값이 안 나오는데 레거시는 Try 안에서 조용히 삼킨다.
그 판정을 TagPreviewCatalog 에 담았다. <b>표로 박는다</b> — 판정 근거는 레거시 함수 본문이
환자·내원 객체를 참조하는지 여부이고, 그건 태그 이름에도 접두어에도 없다.
ETC_현재일자 와 ETC_요양기관명칭_병원명 은 같은 접두어인데 한쪽만 만들 수 있다.
이름 규칙으로 추정하면 절반만 맞는다. 규칙이 확실한 둘(…List·…InitialDatatable / 싸인·직인)만
접미어·낱말로 가르고 나머지는 실측을 옮겨 적었다.
값 계산도 함께 넣었다. 서버 시각 11종은 레거시 Data2Format 과 같은 형식으로 찍고
(영문은 MM-DD-YYYY 재배치), 로그인 사용자 5종은 항목을 골라 잇는다.
ID·이름을 잇는 태그는 한쪽이 비면 점만 남은 값을 만들지 않는다.
단위 테스트 10건. 이 표가 틀리면 화면이 사용자에게 거짓말을 한다 —
값이 나오는 태그를 '안 된다'고 하거나, 안 나오는 태그에 빈 칸을 띄운다.
판정 근거가 레거시 함수 본문이라 코드로는 다시 확인할 수 없어 여기가 유일한 안전망이다.
아직 화면에 붙이지 않았다. 값 줄은 DB 조회(서버 시각·M_UidMst)가 필요하고
그 통로를 만드는 것이 다음 단계다. 판정과 계산은 순수 함수라 그 전에 확정해 둔다.
게이트: 테스트 277/277(신규 10), --edit-smoke 0실패, --modal-check 2/2,
--dialog-shots 57파일, --db-smoke 3000 diff 0·예외 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>