Compare commits

..
112 Commits
Author SHA1 Message Date
MsystechandClaude Fable 5 5771a824d7 '데이터 필드 배선' → '값 가져오기' — 개발자 말을 걷어낸다
사용자가 "어떻게 사용하는지 모르겠다, 개발자가 아닌 사람도 쓸 수 있어야 한다" 고 했다.
화면을 열어 보니 낱말만 문제가 아니었다.

## 낱말

- 창 제목 '데이터 필드 배선' → <b>'값 가져오기'</b>. 전산실에서 '배선'은 랜선·전원이고,
  '배선 지우기' 는 데이터소스나 쿼리가 지워지는 것처럼 읽힌다(실제로는 이 컨트롤 속성만 빈다).
- 인스펙터 행 라벨 '데이터 필드' → '값 가져오기'. 빈 상태 '(배선 없음)' → <b>'(가져오지 않음)'</b> —
  대화상자의 지우기 버튼과 <b>글자까지 같게</b> 두어, 누르면 무엇이 되는지 미리 읽히게 했다.
- '필드' → '가져올 컬럼'. 앱은 이미 결과 창에서 '컬럼 N개', 배선 실패 문구에서
  '결과에 X 컬럼이 없습니다' 로 컬럼을 쓴다 — 낱말을 하나 더 만들지 않는다.
- '형태 Select/Rows' 를 화면에서 지웠다. .NET DataTable API 이름이라
  <b>SQL 을 아는 사람이 오히려 더 확실히 오해한다</b>(여기의 Select 는 SQL SELECT 가 아니다).
- '필터' → '행 조건'. WHERE 연상을 끊는다 — 서버로 가지 않고 이미 받아 온 표를 거르는 식이라
  NVL·TO_CHAR·|| 가 통하지 않는다. 되는 예와 안 되는 예를 나란히 적었다.

## 화면이 "무엇을 먼저 하나"의 답을 '지우기'로 주고 있었다

네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어 넷 다 채워야 하는 것처럼 보였다.
정작 검증이 요구하는 <b>필수는 둘</b>(데이터소스·컬럼)이고, 그 둘이 화면에서 가장 눈에 안 띄는
자리에 있었다. 게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 였다.

①②③ 으로 다시 세웠다 — ① 가져올 데이터소스 ② 가져올 컬럼, ③ '여러 행 중에서 고르기' 는 접는다.
접는 근거는 실측이다: 조건을 쓴 배선이 <b>0건</b>, 행 번호가 0 인 것이 98%.
기본값이 아닐 때만 접힌 머리에 칩('3번째 행' · '조건 있음')을 띄워 숨지 않게 했다.
초기 포커스도 다음 할 일로 보낸다.

## 첫 화면이 굳이 어려운 쪽으로 열리고 있었다

새 배선이 레거시 편집기와 같은 Select 형으로 시작해서, 처음 여는 사람이
<b>평생 안 쓸 개념(조건)</b>을 먼저 봤다. 새 배선을 Rows 형으로 바꿨다.

<b>동작은 같다.</b> 조건이 비면 Select("")(0) 과 Rows(0) 이 같은 행을 가리키고,
해석기 자신이 그 등가를 코드로 인정한다 — DataTableFieldPicker 는 조건이 비어 있으면
필터 경로를 아예 타지 않는다. 기존 값의 형태는 그대로 유지한다(원문을 안 건드리는 것이
왕복 무손실의 조건이다).

## 사용자를 창 밖으로 내보내던 문구

"필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다" —
이 창은 <b>모달이라 밖이 잠겨 있다</b>. 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로
그 문구는 퇴행을 설명하는 문장이었다.

이제 데이터소스를 고르면 그 조회 결과의 컬럼 이름을 보여 준다. 실행은 이 창이 하지 않는다 —
호출부가 MDataTableRunner 를 함수로 감싸 넘기므로 접속·치환·캐시·실패 처리가 한 군데에 남는다.
창은 데이터소스별로 <b>한 번만</b> 묻는다(Refresh 가 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다).
못 읽으면 <b>왜</b> 못 읽는지 말하고 직접 입력 칸을 남긴다.

## 원문은 없애지 않고 접었다

'저장될 값'에 MDataTable1.Select("")(0).Item("") 이 큰 자리로 보이던 것을
사람 말 한 줄로 바꿨다 — "서식을 열면 — MDataTable2 조회 결과의 첫 행에서 ALGYON 값을
가져와 이 칸에 넣습니다." 원문은 접힌 '저장 형식' 줄로 내리고 복사 버튼을 붙였다.

없애지는 않았다. 인스펙터 툴팁·진단 덤프·레거시 문서가 전부 이 표기로 이야기되고,
이 속성에는 자유 텍스트 편집 경로가 없어 무엇이 저장되는지 볼 수 있는 곳이 이 줄뿐이다.

## 곁가지로 고친 조용한 결함 둘

- <b>행 번호가 조용히 0 이 됐다</b>. 전각 '1' 을 넣으면 파싱 실패가 그대로 0 이 되어
  아무 말 없이 첫 행을 가리켰다. 이제 이유를 말하고 확인을 막는다.
- <b>형태를 바꾸면 조건이 조용히 사라졌다</b>. Rows 조립은 Filter 를 아예 쓰지 않는데
  화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다. 다시 열면 영구히 없어진다.
  이제 명시적 버튼이고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
- 데이터소스가 0개일 때 '데이터소스를 선택하세요'(따를 수 없는 지시) 대신 다음 행동을 말한다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과 (옛 문구를 기대한 검사 4건도 새 낱말로 갱신)
- --dialog-shots 69장 전건 통과 — 다크·라이트 양쪽을 눈으로 확인
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변
- <b>저장되는 문자열은 한 글자도 바꾸지 않았다</b> — 화면 문구와 배치만 바뀐다

## 남은 것

컬럼 목록과 값 확인은 DB 접속이 있어야 동작한다. 오프라인 설계에서는 여전히 직접 입력이고,
그때 컬럼명 대소문자 함정이 남는다. '가져올 값 확인'(현재 환자로 실제 한 칸을 뽑아 보여 주기)은
이번에 넣지 않았다 — 환자 세션까지 이 창에 들이는 일이라 별건이다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 12:16:55 +09:00
MsystechandClaude Fable 5 592275fa97 미리보기 툴바를 아이콘으로 — 버튼 여덟 개가 글자 벽이었다
인쇄… / 새로 고침 / 인쇄될 것만 / 환자 선택… / 환자 해제 / 데이터소스… / 누른 것 초기화 /
- / 100% / + 가 전부 글자로 한 줄에 늘어서 있었다. 무엇이 명령이고 무엇이 상태인지
구분되지 않고, 이름이 긴 것들이 자리를 다 먹어 종이 위 툴바가 화면의 한 조각을 차지했다.

셸 제목줄의 아이콘 줄과 같은 규범으로 맞춘다 — 이름은 툴팁이 받는다.

<b>글자로 남긴 것은 값과 상태뿐이다.</b> 고른 환자 이름과 배율 —
둘 다 아이콘으로 대신할 수 없는 정보다.

## 버튼 두 개를 하나로 합쳤다

'환자 선택'과 '환자 해제'가 나란히 있었다. 셸 제목줄은 같은 일을 아이콘 하나로 한다 —
안 골랐으면 바로 선택 창, 이미 골랐으면 바꾸기·해제 메뉴다.
늘 보이는 자리에서 '누르면 해제'로 두면 실수로 지운다는 것이 그 설계의 이유였고,
여기도 같은 자리다. 고른 상태는 아이콘 색으로 알린다.

안 고른 상태의 색을 <b>B.Muted 로 명시</b>했다 — null 을 넘기면 하드코딩 검정으로 칠해져
어두운 툴바에서 혼자 묻힌다(제목줄에서 실제로 겪은 결함이라 같은 실수를 반복하지 않는다).

## 아이콘은 실제로 있는 것만 골랐다

카탈로그에 없는 이름을 쓰면 조용히 대체 글리프로 떨어진다 —
쓰려던 refresh-cw · user-x · database · eraser 는 없어서
rotate-cw · table · undo-2 로 바꿨다(90종 중 실재하는 것만 확인해 썼다).

'인쇄될 것만'은 토글이라 눌린 상태가 곧 '인쇄 기준'이라는 뜻이 된다 — 툴팁이 양쪽을 설명한다.

## 값을 치르는 것

글자가 사라져 처음 쓰는 사람은 툴팁을 한 번 훑어야 한다.
이 도구를 며칠에 한 번 쓰는 사용자에게는 실제 비용이다 —
다만 제목줄이 이미 같은 방식이라 규범이 하나로 모이는 쪽이 낫다고 봤다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과
- --dialog-shots 69장 전건 통과 — 미리보기 툴바를 잘라 2배로 키워 다크·라이트 양쪽을 눈으로 확인
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:40:18 +09:00
MsystechandClaude Fable 5 3e75bdc9b9 쿼리 편집기 — 무엇을 돌렸는지, 컬럼이 무엇인지, 늘 뜨는 경고
## 돌린 SQL 을 창 안에서 볼 수 있다

치환을 마친 문장을 필드에 담아 두기만 하고(진단용) 화면에는 안 내보냈다.
0행이 나왔을 때 <b>치환이 NULL 이라 조건이 사라진 것</b>인지
<b>이 환자에게 자료가 없는 것</b>인지 가르는 유일한 수단이 그 문장인데, 볼 방법이 없었다.

요약 줄에 '돌린 SQL 보기' 를 두고 읽기 전용으로 펼친다(SQL 복사도 함께 —
데이터소스 결과 창과 같은 구성이다). <b>실패했으면 자동으로 펼친다</b> —
표가 어차피 없고, 오라클 오류 문구만으로는 어디가 틀렸는지 알 수 없다.

## 0행이어도 컬럼 머리글을 남긴다

전에는 0행이면 표를 통째로 접었다. 그런데 <b>컬럼 이름은 이미 알고 있고</b>,
그것이 배선 필드 칸에 옮겨 적을 값이다. 접어 버리면 방금 조회한 컬럼명을 알아내려고
쿼리를 또 돌려야 한다 — 0행은 배선 작업에서 드문 상태도 아니다.

## 늘 뜨던 경고를 조건부로

'변수 NULL — 구문만 검사' 가 <b>치환 대상이 하나도 없는 쿼리에도</b> 붙었다.
치환할 것이 없으니 NULL 로 바뀐 것도 없는데 거짓 꼬리표가 상시로 달려서,
정작 진짜 경고가 붙은 날에도 눈에 들어오지 않았다. 늘 뜨는 경고는 경고가 아니다.
치환 시도가 실제로 있었을 때만 붙인다.

## 같은 창이 진입 경로에 따라 달랐다

데이터소스 아이콘에서 편집기를 열면 《컨트롤명》 치환 칩이 나오는데
인스펙터의 쿼리 행에서 열면 그 칩이 <b>통째로 사라졌다</b> — 호출부가 컨트롤 이름을 안 넘겼다.
같은 창이 어디서 열렸는지에 따라 능력이 다르면 안 된다. 인스펙터도 넘기게 했다.

## 판정

- 단위 시험 388 · edit-smoke 405건 전건 통과
- --query-popup(자동완성 팝업 진단) 전건 통과
- --dialog-shots 69장 전건 통과 — 03b 샷에서 치환 변수 없는 쿼리의 거짓 꼬리표가
  사라진 것을 눈으로 확인했다
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

'돌린 SQL' 펼침과 실패 시 자동 펼침은 DB 가 붙은 단말에서 실제 실패 쿼리로 한 번 확인하는 것이 좋다 —
진단은 성공 경로만 찍는다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:27:27 +09:00
MsystechandClaude Fable 5 4b400b94e0 캔버스에서 고른 것을 레이어 목록이 따라오게 한다
1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면 목록에 아무 변화가 없는 경우가 흔했다.
둘 중 하나였다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어 <b>행 자체가 만들어지지 않았거나</b>.

접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다. 그래서 사용자는
"분명히 골랐는데 레이어에는 선택이 없는" 상태를 보고 어느 쪽이 맞는지 판단할 단서를 잃는다.
접기·검색·그룹을 쓸수록 어긋나므로 <b>기존 기능이 오히려 신뢰를 깎고 있었다</b>.

반대 방향(목록 더블클릭 → 캔버스 스크롤·페이지 전환)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
이번 것은 새 기능이 아니라 그 역방향을 잇는 일이다.

## 어떻게

선택이 바뀌면 주 선택의 행을 찾아 스크롤한다. 행이 없으면 조상(페이지 마디·그룹 폴더·컨테이너)을
편다 — 그 결과로 목록이 다시 만들어지고, 재구성 이벤트가 이 경로를 한 번 더 부를 때
행이 있으므로 스크롤만 하고 끝난다. 펴기는 <b>한 번만</b> 시도한다(이미 펴져 있으면 false 를
돌려주므로 재구성이 무한히 돌지 않는다 — 검사로 고정했다).

<b>필터는 건드리지 않는다.</b> 펼 것이 없는데도 행이 없다면 검색·타입 필터에 걸린 것이다.
그건 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다 — 아무것도 하지 않는다.

되먹임은 기존 syncing 가드가 막는다. 목록에서 고르는 경로는 그 가드에 걸려
스크롤이 돌지 않으므로, 클릭하던 자리가 움직이지 않는다.

## 판정

- 단위 시험 388 · edit-smoke 404건 전건 통과 (자동 스크롤 4건 추가)
- --dialog-shots 69장 · --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

검사는 VM 층(조상 펴기·행 생성·재진입 방지)까지다. 실제 스크롤 위치는 ListBox 가상화가
관여하므로 앱에서 긴 서식으로 한 번 확인하는 것이 좋다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:17:06 +09:00
MsystechandClaude Fable 5 6a0331ec8a 레이어 행을 항상 두 줄로 — 둘째 줄에 타입명을 둔다
목록에 1줄 행과 2줄 행이 섞여 있었다. 둘째 줄(이름)이 <b>표시명과 다를 때만</b> 나왔기 때문이다 —
문구가 없는 컨트롤은 표시명이 곧 이름이라 한 줄로 그려졌다.
훑을 때 눈이 매번 자리를 다시 잡아야 하고, 어느 행이 어디서 끝나는지 리듬이 없다.

## 둘째 줄에 무엇을 두는가

<b>타입명</b>이다. 아이콘만으로는 안 갈리는 짝이 실제로 있다 —
라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
그래서 아이콘의 단순 중복이 아니다.

이름(Id)은 <b>더할 정보가 있을 때만</b> 붙인다. 세 갈래로 갈린다.
- 문구가 없다(표시명 = 이름) → 타입명만. "TextBox1 / 텍스트박스"
- 갓 놓았다(문구가 타입명 그대로) → 이름만. 윗줄이 이미 타입을 말했다. "라디오버튼 / RadioButton1"
- 사람이 문구를 썼다 → 둘 다. "환자명 / 라벨 · Label1"

같은 글자가 두 줄에 두 번 나오는 경우가 하나도 없도록 셋을 다 검사로 고정했다.
이름을 계속 보여 주는 이유는 그것이 <b>외부 참조 키</b>이기 때문이다 —
수식(RadioButton1+CheckBox3)·액션 대상 컨트롤·이벤트 매핑이 이름 문자열로 서로를 가리킨다.

## 값을 치르는 것

행 높이가 일정해진 대신 문구 없는 컨트롤이 많은 서식에서 목록이 길어진다.
사용자가 리듬 쪽을 택했고, 타입명이 아이콘 모호성까지 함께 없애므로 값은 한다.

## 판정

- 단위 시험 388 · edit-smoke 400건 전건 통과 (레이어 둘째 줄 5건 추가)
- --dialog-shots 69장 전건 통과 (레이어 4종을 눈으로 대조 — 행 높이 균일 확인)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:10:57 +09:00
MsystechandClaude Fable 5 8c76a978a9 캔버스·인스펙터 잔손질 넷 — 마퀴·치수 칩·죽은 버튼·잘린 설명
## 패널 안에 그린 마퀴가 아무것도 안 잡았다

마퀴가 최상위만 훑고 컨테이너 안으로 안 들어갔다. 패널 안 체크박스 5개를 한 번에 고르려고
안쪽을 끌면 패널만 잡히거나 아무것도 안 잡혀서, Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
컨테이너 자식은 전체 컨트롤의 <b>18.3%</b>(실측 18,749개/컨테이너 4,099개)라 드문 경우가 아니다.

규칙은 <b>'담김'</b>이다 — 마퀴가 컨테이너 사각형 안에 들어가 있으면 그 안의 것을 고른다
(내 상자가 패널보다 작으니 그 안을 고르려는 뜻이다). 걸치기만 하면 예전처럼 패널이 잡힌다.
모드도 수정키도 없이 <b>그린 모양만으로</b> 갈리므로 예측할 수 있다.

한 군데를 더 고쳐야 했다. 클릭 선택이 쓰는 GroupMatesOf 는 자식을 부모 컨테이너로 되돌리는데
(패널 안의 것을 집으면 패널이 움직여야 하므로 옳다) 마퀴가 그것을 그대로 쓰고 있었다 —
안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어 위 규칙이 아무 일도 못 한다.
마퀴 전용 경로를 따로 뒀다. 걸치기 동작이 그대로인 것도 검사로 고정했다.

## 끄는 동안 숫자가 캔버스에 없었다

라벨 폭을 종이 칸에 맞추는 동안 숫자는 300px 떨어진 인스펙터에만 떴다 —
끌고, 손 떼고, 오른쪽 보고, 다시 끈다. 간격 수치 칩은 <b>균등 스냅이 성립할 때만</b> 나타나므로
"지금 이게 몇 px 인가"는 드래그 중에 알 방법이 아예 없었다.

이동이면 좌표, 크기 조절이면 크기를 보여 준다 — 지금 <b>바꾸고 있는 값</b>이 그것이다.
오버레이 층이라 종이에는 없고, 줌이 걸린 층이라 역배율로 글자 크기를 화면 고정으로 만든다.

## 눌러도 아무 일이 없는 ⋮ 버튼

Command·Click·ContextMenu 가 <b>전부 없었다</b>. 그런데 툴팁은 '이 컨트롤에 대한 명령'이라
"단일 선택일 때만 되는 뭔가가 있다"고 오해시켰다. 없는 버튼보다 죽은 버튼이 나쁘다 —
컨트롤 단위 명령은 캔버스 우클릭 메뉴에 이미 다 있다.

## 주석이 논증한 값과 실제 값이 달랐다

라벨 폭 주석이 "92px + 줄바꿈이면 두 줄로 접혀 전부 읽힌다"고 근거까지 적어 놓고
값은 72 였다. 그래서 '액션 대상 컨…' 처럼 잘렸다. 92 로 맞췄다.

같은 자리에서 설명 행(ReadOnlyRow)이 값처럼 잘리고 있었다 —
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
<b>지시에 해당하는 뒷부분</b>이 사라져 무엇을 하라는 건지 알 수 없었다. 접어서 다 보여 준다.

## 판정

- 단위 시험 388 · edit-smoke 396건 전건 통과 (마퀴 2건 · 치수 칩 4건 추가)
- --dialog-shots 69장 전건 통과 (인스펙터 8종을 눈으로 대조 — 라벨·설명 잘림 없음)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 09:05:30 +09:00
MsystechandClaude Fable 5 f6b456753c 환자 선택 — 첫 걸음이 틀렸다는 것을 화면이 말하게 한다
## 가장 흔한 입력이 조용히 0건이었다

차트번호는 <b>완전일치</b>다. 병원 P_PatInf 는 고정 폭이라 실제 값이 0으로 채워져 있는데
사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데(PatientVisitStore.cs:8)
유일한 호출부가 그 계약을 안 지키고 있었다.

<b>자릿수를 설정으로 정하지 않았다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
대신 0건일 때만 8·10자리로 한 번 더 찾는다(레거시가 쓰던 두 폭이다).
찾았으면 "자릿수를 채워 '0000012345' 로 찾았습니다"를 적고 그 번호를 칸에 되쓴다 —
다음부터 그 번호를 쓸 수 있게. 틀릴 여지가 없는 방식이다.

## 0건일 때 무엇으로 찾았는지 말하지 않았다

유형이 틀려서 0건인 경우가 가장 흔한데(습관대로 이름을 쳤는데 유형은 차트번호)
화면에 힌트가 하나도 없어 접속이나 권한을 의심하게 된다.
"없습니다(차트번호 완전일치로 찾았습니다)"로 유형과 일치 방식을 함께 적는다.

## '기준 시점'이 무슨 뜻인지 알 수 없었다

입원 내원은 <b>구간</b>이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 하는데,
목록에 접수일시만 있어서 그것이 구간이라는 사실 자체가 안 보였다.
이미 조회돼 있으면서 화면에 없던 <b>퇴원일시</b> 열을 붙이고,
선택지를 '마지막(퇴원 시점)'·'처음(입원 시점)'으로 바꿔 무엇을 고르는지 글자로 말한다.

열을 하나 더하면서 상태 열이 화면 밖으로 밀리고 콤보 글자가 잘렸다 —
사진으로 확인하고 일곱 열이 다 들어오도록 폭을 다시 잡았다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과
- --dialog-shots 69장 전건 통과(다크·라이트 눈으로 확인 — 열·콤보 잘림 없음)
- --modal-check · --maxrect · --scale-budget 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 남은 것

검색 유형 자동 판별(입력 모양으로 주민번호·휴대전화·성명·차트번호를 가려내는 것)은
안 넣었다 — 콤보를 사람 몰래 바꾸는 동작이라 "왜 유형이 저절로 바뀌지"를 새로 만든다.
0건 안내가 유형을 밝히게 된 것으로 같은 혼란의 대부분은 없어진다.
자릿수 보정 뒤 재조회는 DB 가 붙은 단말에서 실제 차트번호로 확인이 필요하다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:48:13 +09:00
MsystechandClaude Fable 5 d83478619e 쿼리 편집기 — Enter 가 새던 것과 지나간 검증 결과
## Enter 한 번에 변수를 못 넣은 채 저장되고 닫혔다

치환 변수 검색칸에 KeyDown 이 없었고 확인 버튼은 IsDefault 다.
그래서 '차트' 를 치고 Enter 를 누르면 검색도 삽입도 아니라
<b>SQL 이 저장되고 창이 닫힌다</b>. 변수를 하나도 못 넣은 채 편집이 끝나는데,
무엇이 일어났는지 알기도 어렵다 — 창이 사라졌으니 되짚을 화면이 없다.

Enter 는 지금 걸러진 목록의 선택 항목을 넣는다(검색으로 하나까지 좁혀진 경우가 대부분이다).
↓ 는 목록으로 이동한다 — 손을 마우스로 옮기지 않고 훑을 수 있어야 한다.
둘 다 e.Handled 로 기본 버튼에 닿지 않게 막는다.

## 검증한 뒤 SQL 을 고쳐도 초록 요약이 그대로 남았다

"검증했다"고 믿고 저장하는데 저장되는 것은 <b>검증하지 않은 문장</b>이다.
검증 시점의 원문을 기억해 두고, 지금 텍스트와 다르면 결과 칸을 흐리게 낮추고
요약 앞에 '(수정됨 — 다시 실행하세요)'를 붙인다.

<b>결과를 지우지는 않는다.</b> 방금 본 컬럼명이 사라지면 그것대로 불편하다 —
배선 필드 칸에 옮겨 적는 중일 수 있다. 유효하지 않다는 사실만 말한다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과
- --query-popup(자동완성 팝업 진단) 전건 통과
- --dialog-shots 69장 · --modal-check · --maxrect 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

## 남은 것

조사에서 함께 나온 것 중 이번에 안 한 것들 — '돌린 SQL 보기'(치환을 마친 문장을 창 안에서 확인),
0행일 때도 컬럼 머리글 남기기, 치환 변수를 안 쓰는 쿼리에 늘 붙는 '구문만 검사' 꼬리표 제거,
인스펙터로 연 편집기에서 컨트롤 변수 칩이 사라지는 것.
전부 근거가 잡혀 있고 다음 회차에 이어서 할 수 있다.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:44:53 +09:00
MsystechandClaude Fable 5 e162d0fb6b 태그가 값과 사유를 사실대로 말하게 한다
세 가지가 <b>사실과 다른 말</b>을 하고 있었다.

## ① 로그인 사용자 태그 다섯 갈래가 피커에서만 비어 보였다

피커가 TagPreviewCatalog.FromUser 의 <b>5-인자 위임판</b>을 부르고 있었다.
그 판은 면허·전문의·직종·부서·부서전화를 빈 문자열로 채운다.

    FromUser(tag, id, name, mobile, office)
      → FromUser(tag, id, name, mobile, office, "", "", "", "")

그래서 의사면허번호·전문의번호·직종·부서·부서전화 태그를 피커에서 고르면
"이 계정에는 값이 비어 있습니다"가 뜨는데, 정작 종이에는 실제 값이 찍힌다.
사용자는 그 태그를 못 쓰는 것으로 판단하고 다른 것을 찾게 된다.

이 값들은 로그인 세션(HisUser 15컬럼)이 이미 들고 있다 — 조회를 더하지 않고 본판으로 넘긴다.

## ② 액션 태그 149종에 데이터 태그용 사유가 나갔다

액션 태그는 <b>값이 아니라 동작</b>이다(더블클릭·버튼이 실행한다).
그런데 피커가 데이터 태그와 같은 값 칸을 그려서
"환자 정보가 있어야 값이 나옵니다" 같은 문구가 149종 전부에 붙었다.
오른쪽 설명 칸이 통째로 거짓말을 하고 있었다.

행 종류를 받아 액션이면 "값이 아니라 동작입니다 — 더블클릭·버튼에서 실행됩니다" 한 줄만 둔다.

## ③ 오타 난 태그가 아무 표시 없이 커밋됐다

인라인으로 직접 치는 것이 이 행의 <b>주 경로</b>인데 거기에 검증이 하나도 없었다.
'PAT_이룸' 처럼 한 글자만 틀려도 조용히 저장되고, 그 사실은 미리보기에서 <b>빈칸</b>으로만 드러난다 —
빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 방법이 없다.

글꼴 행과 <b>같은 규칙</b>으로 ⚠ 를 붙인다. 막지는 않는다 —
사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다. 값은 한 글자도 건드리지 않는다.

## 곁가지 — 사유가 접속에 묶여 있었다

DB 가드가 값 계산보다 <b>위</b>에 있어서, 접속이 없으면 '값을 만들 수 없습니다' 사유까지
통째로 감춰졌다. 그 사유는 정적 판정이라 접속과 무관하다 —
정작 가장 필요한 상황(접속 안 된 단말에서 서식을 훑을 때) 안 보였다.
가드를 값 계산 직전으로 내렸다.

## 판정

- 단위 시험 388 · edit-smoke 390건 전건 통과 (태그 6건 추가)
- --dialog-shots 69장 전건 통과
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-21 08:42:03 +09:00
MsystechandClaude Fable 5 73095c1415 레이어 목록이 계층과 범위를 말하게 한다
사용자가 콕 집어 "레이어 구분이 잘 안 된다"고 했다. 원인은 색이 아니라 <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>
2026-08-20 18:43:10 +09:00
MsystechandClaude Fable 5 f8531cb7e1 데이터소스 배선이 실사용에서 전부 죽어 있었다
구조 사용성 조사를 돌렸더니 가장 무거운 것이 사용성이 아니라 <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>
2026-08-20 18:37:57 +09:00
MsystechandClaude Fable 5 f4e239075d 창 내용 여백을 16 으로 맞추고, 거짓이 된 주석을 고친다
앞 커밋에서 남겨 둔 것 — 타이틀바는 통일했지만 내용 여백은 창마다 달랐다.
실측하니 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>
2026-08-20 18:00:27 +09:00
MsystechandClaude Fable 5 13edf3ecd9 제목줄 환자 아이콘이 혼자 까맸다
지적을 받고서야 실제로 봤다. 앞 커밋에서 "제목줄의 환자 아이콘 버튼은 손대지 않았다 —
옆 아이콘들과 같은 스타일·크기다" 라고 썼는데, <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>
2026-08-20 17:56:36 +09:00
MsystechandClaude Fable 5 33461a6c67 환자 선택 창을 확인하다 버튼 두 가지를 고친다
창 자체는 앞 커밋의 공통 크롬을 그대로 받아 타이틀바가 붙었다. 확인하다 두 가지가 걸렸다.

## '찾기(_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>
2026-08-20 17:50:51 +09:00
MsystechandClaude Fable 5 12e5ec6452 열리는 창들의 결을 통일한다 — 타이틀바와 강조 버튼
## 창 열둘 중 하나만 우리 옷을 입고 있었다

커스텀 크롬을 가진 것은 메인 셸과 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>
2026-08-20 17:42:12 +09:00
MsystechandClaude Fable 5 020595f8d4 사용성 2순위 — 있는 명령에 닿는 길을 낸다
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>
2026-08-20 17:28:43 +09:00
MsystechandClaude Fable 5 fccd441193 사용성 1순위 — 조용히 틀리게 동작하던 넷을 고친다
사용성 감사를 돌렸더니 결론이 뜻밖이었다. <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>
2026-08-20 17:16:15 +09:00
MsystechandClaude Fable 5 be9d697a0d 글꼴을 목록에서 고른다 — 매번 타이핑하지 않는다
글꼴 칸이 자유 입력 텍스트 하나였다. "돋움"을 매번 손으로 쳐야 했고,
한 글자만 틀려도 레거시는 <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>
2026-08-20 16:30:49 +09:00
MsystechandClaude Fable 5 d45b674e29 여러 개를 골라 공통 속성을 한 번에 바꾼다
커밋 규약은 그대로다 — 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>
2026-08-20 16:24:16 +09:00
MsystechandClaude Fable 5 8cc2d68ac1 미리보기를 눌러 볼 수 있게 하고, Ctrl+드래그를 복사로 바꾼다
사용자 지적 두 건. 둘 다 라디오 배타 그룹이 걸린 문제였다.

## 미리보기 클릭이 안 먹었다

레거시 미리보기(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>
2026-08-20 15:07:51 +09:00
MsystechandClaude Fable 5 d965b3f217 라디오버튼도 패널로 묶는다 — 차단을 경고로 (운영 표준 패턴을 막고 있었다)
조사 워크플로(읽기 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>
2026-08-20 14:44:20 +09:00
MsystechandClaude Fable 5 345b2e1da1 싸인 이미지 SMB 직접 읽기 — 공유 매핑 구현(서버측 SMB 개방 대기)
사용자 결정(②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>
2026-08-20 12:40:56 +09:00
MsystechandClaude Fable 5 a0333791b6 싸인·직인·로고 17종 — 경로 조회와 이미지 렌더 이식(전송 계층은 재현 불가)
워크플로 조사(읽기 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>
2026-08-20 10:18:35 +09:00
MsystechandClaude Fable 5 2310971a10 인쇄 용지를 문서 크기로 제시 + Select 형 배선 필터 실행 — 남은 과제 2건
① 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>
2026-08-20 09:35:27 +09:00
MsystechandClaude Fable 5 31a05c205b 값을 못 만든 태그는 종이·미리보기에서 빈칸 — 사유 표기 제거(사용자 결정)
지금까지는 값이 안 되는 태그를 [태그명 — 사유] 로 그렸다("못 만든
것을 감추지 않는다"). 실사용 출력물에 그 문구가 찍히면 안 되므로
빈칸으로 바꾼다 — 태그 이름도 지운다(남기면 값이 나온 줄 안다).
렌더 공용 지점(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>
2026-08-20 09:18:46 +09:00
MsystechandClaude Fable 5 86ecfff1a1 퇴원일시_영문·퇴원일자_낮병동 — 257종 → 259종, 값 태그 이식 완주
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>
2026-08-20 09:06:00 +09:00
MsystechandClaude Fable 5 55c1a7fcf1 재원일수 파생 2종·병동 수간호사 — 254종 → 257종
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>
2026-08-20 09:03:10 +09:00
MsystechandClaude Fable 5 edb9cc65cf 비급여처방·전문그룹과목·청구진료과목·임신주기 4종 — 250종 → 254종
OCM_비급여처방(bzDataInterface.vb:10964, Case Else + SRCH 조건 갈래 —
고지 대상(SgaNtiNon='Y')·수가 그룹 존재만. TYRCH UNION·BSGH 치료실·
GNRH·BSQ·SUSS 분기 미이식). 보험 코드로 금액 컬럼을 골라 0 초과만
"이름(##,###)원" CRLF 결합. 특수 계산코드(SGACFG Use_SgaCA1/2)
환자군은 레거시가 SELECT 에 없는 컬럼을 읽어 항상 빈 값(무증상
실패) — 설정을 읽어 그 환자군이면 사유로 말한다(이 DB 에 설정이
실제로 1건 있다 — ㉟ 확인).

OCM_진단서전문그룹과목(:11245 — DepBilCod='01'+세부과목이면 MEDDTL
이름, 아니면 GDPTBL 그룹과목. 원문의 0행 Rows(0) 크래시는 사유로).
OCM_청구진료과목(:11398 — 문맥 진료과 행의 DepBilCod 를 BDPTBL 로.
부서 시점과 유효기간 시점이 어긋나는 원문 특이점 그대로).
OCM_임신주기(:11845 — 사용자 설정(EDD/LMP) 조회가 코드 컬럼에
사용자 이름을 비교하는 결함으로 항상 0행 → 기본값 LMP 고정.
LMP 산식만 옮기고 도달 불가한 EDD 산식은 미이식. 주수는 총일수/7·
나머지, 월경일이 미래면 음수 일수만 — 원문 그대로).

- dotnet test 342/342 · --edit-smoke 실패 0 (이름 검사 254종)
- --db-patient ①~㊱ 전건 통과 (그룹과목 실값 3컬럼)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:59:49 +09:00
MsystechandClaude Fable 5 199a4142ee 퇴원약 8종 — 242종 → 250종
공통 조회(퇴원약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>
2026-08-20 08:55:08 +09:00
MsystechandClaude Fable 5 03fff0a3a4 수술 파생 6종 — 236종 → 242종, ㉘ 수술 검증이 SKIP 에서 실검증으로
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>
2026-08-20 08:51:30 +09:00
MsystechandClaude Fable 5 02657b3893 상병 조합 11종 — 225종 → 236종
전부 기존 상병 조회(Okd 공용 쿼리, Scope All/Main/Sub) 재사용 —
왕복이 늘지 않고 결합·표기만 다르다(bzDataInterface.vb:8825-10481,
Case Else 갈래. SHCTR 무하이픈·BSGH·JEGG 배제·SPHH 본문 분기 미이식):

OCM_상병코드_상병명("코드-명(확진/의증)" 줄단위),
OCM_진단구분_상병코드_상병명("[주진단명] : "/"[의증]     : "(공백 5)/
"[부진단명] : "), OCM_진단구분_상병명("[주] /[부] "+명+확의),
OCM_상병명_상병코드(확진 접미가 " - 확증" — 레거시 오타로 보이지만
그대로), OCM_주상병일자(무구분 이어붙임 — 복수 주상병이 한 줄로
붙는 원문 그대로. 등록일시 12자 미만이면 레거시 예외→빈 값 재현),
OCM_상병코드_상병명_주상병("1. " 고정 번호 + PadRight 15),
OCM_상병코드_상병명_부상병(번호가 0부터 시작하는 결함 보존),
OCM_부상병명, OCM_주상병명_영어, OCM_주상병명_주상병코드.

OCM_주상병특정기호는 사유 완결 — 심사 상병 공용 쿼리(Bkd)의 SELECT
목록에 특정기호(OkdVeeCod)가 없어 행이 있으면 컬럼 예외→빈, 없어도
빈. 항상 빈 값인 확정 결함이다.

이름 게이트가 OCM_주상병명_주상병코드를 카탈로그 미등재로 잡았다 —
레거시 원문(:10429) 실존을 확인하고 등재(전수 추출 이후 추가된 함수).

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-20 08:46:26 +09:00
MsystechandClaude Fable 5 8256b79800 나이 계열 7종·주민번호 마스킹 3종 — 215종 → 225종 (조사 라운드 7 시작)
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>
2026-08-20 08:41:22 +09:00
MsystechandClaude Fable 5 abb08d0c6b 태그 분류 정정 — 이름 규칙과 실제 반환형이 어긋나는 10종
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>
2026-08-20 07:41:51 +09:00
MsystechandClaude Fable 5 d988a347f4 영상촬영이력 — 213종 → 214종
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>
2026-08-19 19:31:49 +09:00
MsystechandClaude Fable 5 f06740ae51 적용진료과·전과·간호정보 과거력 4종 — 209종 → 213종
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>
2026-08-19 19:29:33 +09:00
MsystechandClaude Fable 5 7dcc4c2e48 진단서 의사·수술일자·외래실진료일 16종 — 193종 → 209종 (조사 라운드 6)
진단서 계열 8종(bzDataInterface.vb:4380-4845): 로그인 사용자가
의사(UidDtrYon='Y')면 로그인 사용자의 값, 아니면 담당의의 값 —
시점·기준일(입원 CodEndDtm·외래 접수일)이 담당의 프리페치와 같은
판정이라 그 행을 재사용한다. 의사명(UidNam)·명_외래(입원이면 조회
없이 빈)·영문(UidEngNam)·면허(UidLicNum)·전문의(UidSpcLic)·
세부전문의(UidDtlSpcLic)·전문과목(개원일 2022-07-01 분기, TYRCH
미이식)·청구진료과목_로그인(DepBilCod→BDPTBL, 원문의 같은 쿼리 2회
실행은 1회로). 로그인 행에 영문명·세부전문의번호 컬럼 2개 추가.

값 5종: OCM_당뇨식칼로리_List(:3569 — 원문부터 결정적),
OCM_수술일자(:2977 Case Else — 첫 수술 OprKey ASC; PURME·BSGH·
HIMCHAN·GJHNSS 분기 미이식), OCM_수술일자_마지막수술(:2999),
OCM_외래실진료일/_과목별(:1217/:1503 — 신환 첫 접수(폴백 전체 첫
접수)~마지막 접수 구간의 과 매칭 접수일 전부 "YYYY/MM/DD" ", " 결합.
같은 날 중복 출력도 원문 그대로. 본체 종료일 쿼리의 문자열 연결은
같은 값이라 바인드로 정상화).

사유 완결 3종: OCM_외래실진료일수(:1645 — 차트번호 자리에 시작일
바인드 + 없는 컬럼(OcmAcpDtm) 정렬, 상시 ORA-00904→빈 값인 레거시
결함), ETC_승인의사(:3892 — EmrKey 의존, 미리보기 부재),
OCM_수술소독간호사(:3028 — PURME 전용, 그 외 병원은 빈 SQL 실행).

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 209종)
- --db-patient ①~㉟ 전건 통과 (㉟ 4종 추가 — 청구과목 실값 4자)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:23:25 +09:00
MsystechandClaude Fable 5 4e6e086ece 유형·수납·진료기간·응급증상·전과내역 8종 — 185종 → 193종
OCM_계산유형(bzDataInterface.vb:5133 — 문맥 자격의 보험·계산 코드로
M_CalMst.CalNam), OCM_보험유형(:5186 — M_InsMst.InsNam),
OCM_수납일시(:5287 — 자격의 수납청구→수납 갱신일시. 원문 두 쿼리 다
비결정 Rows(0) — 최신 청구의 최신 수납으로 고정, 한 왕복으로 접음),
OCM_진료기간(:2236 — SRCH 갈래: 퇴원이면 접수~퇴원, 재원이면 최신
퇴원약 오더→퇴원예정→오늘 순. 외래: 최초 접수~최신 접수/오늘.
HANKOOK·SRH·GUPOSS 분기 미이식), OCM_진료기간_진료과(:2575 — 입원만
값. 외래는 두 번째 쿼리의 진료과 바인드 미등록(레거시 결함, :2644)으로
상시 ORA-01008→빈 값 — 사유로 완결), OCM_응급증상여부_YES/NO
(:12748/:12830 — 응급의료관리료 오더의 산정 여부. 코드 목록은
GetDtlMst("EMGEMGCOD") 그대로 + 0건이면 하드코딩 6종 폴백.
행이 없으면 레거시 $NoCheck$ — 체크 상태를 정하지 않는다.
SJRCH 분기 미이식), OCM_전과내역(:1103 — 과명+공백7+구간, CRLF 결합.
과 조인이 의사 조인에 종속되는 원문 모양 유지).

체크류 렌더 보완: 값을 못 만든 태그(사유)는 체크박스·라디오의 컨트롤을
그대로 둔다 — 레거시도 SetValue 를 건너뛴다. 라벨에 사유를 쓰면
"남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 193종)
- --db-patient ①~㉟ 전건 통과 (㉟ 에 7종 추가 — 보험유형 실값 4자,
  응급 쿼리의 ORDER BY ROWID 모호성은 OdrKey 타이브레이크로 수정)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:16:37 +09:00
MsystechandClaude Fable 5 7bae9b9057 부가정보 2차분 20종 — 165종 → 185종 (워크플로 조사 라운드 4·5)
병렬 조사(읽기 11 + 반박 검증 11)로 39종을 분석하고 그중 20종을 이식:

값 18종 — OCM_발병일(:8125, 발병시작일 최신 고정), OCM_당일식이
(:12620, PIVOT 원문 유지·"아침 : X " 결합), OCM_알러지+조영제/약물/
음식/기타(:14941-15140, LISTAGG CRLF — 카테고리는 한글 리터럴),
OCM_알러지_List(:15142 — " - " 구분자가 한 번 켜지면 안 꺼지는
스티키 결함 보존), OCM_외출예상시작/종료일시(:15197/:15231 — 같은
행의 양끝, 원문부터 결정적), ETC_건강보험증번호(:15269 — PisInsCod=1
숫자 리터럴 유지), ETC_재해발생일(:15323), ETC_산재지정번호(:15727 —
진료과→병원 HspWrkNum, 문맥 시점), OCM_감염분류_감염정보(:14084),
OCM_퇴원분석_수술처치명(:13920 — 후행 개행까지 원문), OCM_병실인실
(:7065, 최신 입실 고정), OCM_병실차액(:7103 — 원문부터 첫 입실
결정적), OCM_VITAL접수일시_LAST(:12996 — 시각만 정렬하는 원문 유지),
OCM_진료과_전화번호(:2773 — 진료과 행 재사용, 왕복 0), OCM_전과일시/
전과일자(:12985/:12990 — 문맥 진료 행).

사유 완결 2종 — OCM_감염정보(:14122 원문 SQL 의 바인드 콜론 누락
→ 상시 ORA-00904 → 빈 값. 고치면 없던 값이 생기므로 사유로 안내),
OCM_Cosign_의사명(:14812 — 저장 기록 키(EmrKey) 의존, 미리보기 부재).

검증이 잡은 레거시 결함 3건 기록: 감염정보 콜론 누락, 알러지_List
스티키 구분자, 진료기간_진료과 SQL B 의 sDepCod 미바인드(다음 배치).

- dotnet test 338/338 · --edit-smoke 실패 0 (이름 검사 185종)
- --db-patient ①~㉟ 전건 통과 — ㉟ 신설: 새 조회 12종 실행 확인
  (O_OkdInf·O_ImlInf·O_OutAdmInf·S_AlgInf·S_IcnInf·S_DssOpn 전부 존재)
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 19:09:38 +09:00
MsystechandClaude Fable 5 a33229b5e1 ETC_접속유저_부서번호 — 164종 → 165종
이름은 "부서번호"지만 값은 로그인 사용자 부서의 전화번호다
(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>
2026-08-19 18:57:12 +09:00
MsystechandClaude Fable 5 7f147a3012 혈액형·LMP·BST·체중 파생 8종 — 156종 → 164종
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>
2026-08-19 17:16:00 +09:00
MsystechandClaude Fable 5 f2f4cf81d3 협진과·입퇴원의사 4종 — 152종 → 156종
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>
2026-08-19 17:08:58 +09:00
MsystechandClaude Fable 5 c0ad782b6a 진료과 파생·입퇴원과 8종 — 144종 → 152종, OCM_진료과 개원일 분기 복원
진료과 파생 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>
2026-08-19 17:04:26 +09:00
MsystechandClaude Fable 5 975aff4211 입·퇴원 일시와 재원일수 13종 — 131종 → 144종
FromStay 12종(bzDataInterface.vb:7152-7940, 전부 Case Else 갈래):
입원시간(HH:MM)·입원일시(이름과 달리 날짜만 — SJRCH·BSYD 만 시각
포함)·입원일시_영문·입실시간(ComTrsDtm 슬래시 12자)·의사퇴원예고일시
(ComSchLev)·퇴원예정일시(ComLevSch)·퇴원시간·퇴원일시·퇴원예고일시
(ILV→ComLevDtm / ILP→ComLevSch)·재원일수·퇴원예고재원일수
(DateDiff(Day)+1). FromExtra 에 입원일자_낮병동(:7260 — P_DcpInf
구간 매칭, 시작일 최신 행 고정).

MsgBox 갈래("퇴원일자를 현재로 설정하시겠습니까?")는 조회 중 대화
상자라 옮기지 않고 사유로 말한다. 그 질문을 건너뛰는 개원일 분기
(HspStrDte>=20200901 → 빈, 외래 <20190501 → 오늘)는 그대로 옮겼다 —
개원일과 서버 오늘을 요양기관 행에 컬럼 2개로 추가(HspStrDte,
ServerToday). 재원일수의 HspCod >= "KBJY" 는 등호가 아닌 크기비교
(레거시 오타로 보임) — MsgBox 갈래라 사유로 흡수된다.

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

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 16:56:54 +09:00
MsystechandClaude Fable 5 ec47c98bdd 내원 이력 6종 — 125종 → 131종
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>
2026-08-19 16:50:25 +09:00
MsystechandClaude Fable 5 4642439ffd 환자 부가정보 7종 — 118종 → 125종
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>
2026-08-19 16:45:03 +09:00
MsystechandClaude Fable 5 adb3572e40 입통원구분·성별표기·내원일시 8종 — 110종 → 118종, 체크류 SetValue 의미론
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>
2026-08-19 16:33:58 +09:00
MsystechandClaude Fable 5 ba6da2252f 병리판독의·진단검사의 4종 — 106종 → 110종
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>
2026-08-19 16:18:37 +09:00
MsystechandClaude Opus 5 4dad271e9c 수술 일정 6종을 옮긴다 — 100종 → 106종
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>
2026-08-19 16:07:27 +09:00
MsystechandClaude Opus 5 e065c5a645 로그인 계열 6종 — 세션 사용자 행을 넓혀 해결. 95종 → 100종 + Dead 1
## 세션 조회 확장(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>
2026-08-19 16:03:54 +09:00
MsystechandClaude Opus 5 94bdcc763f 바이탈 17종 + 주치의 1종 + _Refer 사유 정정 — 76종 → 95종
## 바이탈(측정치) 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>
2026-08-19 15:59:04 +09:00
MsystechandClaude Opus 5 3e9d5b5cf3 STFDTR(주치의)을 옮기고 _Refer 계열의 진실을 밝힌다 — 75종 → 76종 + 사유 정정 ~40종
## 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>
2026-08-19 15:51:29 +09:00
MsystechandClaude Opus 5 26d9606d1e 요양기관 12종을 옮긴다 — 왕복 12회를 1회로 접었다. 63종 → 75종
ETC_요양기관명칭_* 계열(bzDataInterface.vb:15656-16709). 12종 전부
<b>같은 조인(M_DepMst⨝M_HspMst)의 다른 컬럼</b>이라는 것을 본문에서 확인했다 —
병원명(이미 이식)·병원명_영문·병원대표자·병원장·병원장귀하·전화번호·팩스번호·
전화_팩스·영문_전화_팩스·주소·주소_영문·사업자등록번호·요양기관기호.

레거시는 태그마다 이 조인을 따로 돌린다(12종 = 왕복 12회).
컬럼 9개를 한 번에 뽑아 행으로 들고, 값 선택은 해석기가 한다.
HospitalName(문자열) → HospitalRow(행) 로 일반화했다.

접미·결합도 본문 그대로다:
  병원장 = 병원명+"장" / 병원장귀하 = +"장 귀하"
  전화_팩스 = "Tel : … FAX : …" / 영문판 = "Tel : +82 - … FAX : +82 - …"
접두·접미가 붙는 태그는 <b>재료 컬럼</b>으로 빈 값을 판정한다 —
병원명이 비면 "장"만 찍히는 것을 값으로 세지 않는다.

실DB 확인: --db-patient ㉔ 가 9열·병원명 6자를 돌려줬다.

## 게이트

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:42:26 +09:00
MsystechandClaude Opus 5 ef4f36419f 심사 상병 4종을 옮긴다 — 59종 → 63종. 워크플로가 조사한 new 는 이제 상병·수술·심사까지 끝났다
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>
2026-08-19 15:25:49 +09:00
MsystechandClaude Opus 5 460f6c4a91 수술 8종을 옮긴다 — 그리고 이 시험 DB 에는 O_OprInf 가 없다는 것을 알았다. 51종 → 59종
## 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>
2026-08-19 15:22:16 +09:00
MsystechandClaude Opus 5 0da3305a75 상병 11종을 옮긴다 — SQL 은 원문에서 기계 추출했다. 40종 → 51종
## Okd_상병쿼리(bzDataInterface.vb:14160-14623)의 이식

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

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

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

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

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

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

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

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

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

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

## 게이트

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 15:15:29 +09:00
MsystechandClaude Opus 5 9278b39a6d conflict 6건을 고치고 재료 재사용으로 10종을 더한다 — 30종 → 40종
## 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>
2026-08-19 15:05:38 +09:00
MsystechandClaude Opus 5 e62e43d176 진료과·병동·병실·요양기관명을 옮긴다 — 26종 → 30종
사용자가 서식에서 빈칸으로 확인한 자리들이다(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>
2026-08-19 14:54:18 +09:00
MsystechandClaude Opus 5 db969fc6b5 인쇄 경로가 해석기를 잃고 있었다 — 화면과 다른 종이가 나왔다
사용자가 미리보기 화면과 실제 인쇄 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>
2026-08-19 14:47:46 +09:00
MsystechandClaude Opus 5 41e6961b0e CTX 통합 계획 — 순증 48종, 고칠 것 6건. 그리고 내 문서가 하나 틀렸다
검증 통과 태그를 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>
2026-08-19 14:25:34 +09:00
MsystechandClaude Opus 5 dd96874c7c SQL 태그 231종을 조사한다 — 그대로 옮기면 안 된다는 것이 결론이다
오케스트레이션 에이전트 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>
2026-08-19 14:00:02 +09:00
MsystechandClaude Opus 5 fd295744dd CTX 126종 검증을 끝낸다 — 반박 6건, 그리고 기존 코드의 차이 3건
배치 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>
2026-08-19 13:20:45 +09:00
MsystechandClaude Opus 5 949db1aa18 워크플로 1차 결과를 저장소로 옮긴다 — 검증이 10건을 반박했다
오케스트레이션으로 레거시 본문을 병렬로 읽고 각 매핑을 다른 에이전트가
<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>
2026-08-19 13:00:37 +09:00
MsystechandClaude Opus 5 5ec31cee41 나이 개월·일 계산 두 종을 순수 함수로 옮긴다 — 레거시가 달력을 안 쓴다
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>
2026-08-19 12:03:18 +09:00
MsystechandClaude Opus 5 99b8aaa9c2 남은 360종 색인을 만들고, 앞선 분류를 정정한다 — 옮길 수 없는 태그는 없다
## 정정

전에 "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>
2026-08-19 11:59:57 +09:00
MsystechandClaude Opus 5 5bf2cf034d OCM_담당의사 3종을 옮긴다 — 처음으로 레거시와 의도적으로 다르게 간다
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>
2026-08-19 11:50:25 +09:00
MsystechandClaude Opus 5 942780ae97 C 를 먼저 세 봤다 — 25,488건이 걸린다. "레거시 그대로"는 선택지가 아니다
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>
2026-08-19 11:45:00 +09:00
MsystechandClaude Opus 5 3e9f50ee52 OCM_담당의사 배치의 SQL 을 확보하고, 옮기기 전에 정할 것 둘을 적는다
조회 두 개(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>
2026-08-19 11:39:50 +09:00
MsystechandClaude Opus 5 d6d594490c 태그 이식 순서를 문서에 못 박는다 — "모두"는 배치로만 갈 수 있다
남은 363종의 성격이 서로 달라 한 번에 갈 수 없다.
232종은 각각 자기 SQL 을 든 별도 이식이고,
48종은 MessageBox 를 띄워 미리보기에서 원리상 실행할 수 없다.

다음 배치를 순서대로 적어 뒀다. OCM_담당의사 계열이 먼저인 이유는
그것이 요구하는 조회 둘(GetCodInfDT·GetUidMst)을 만들고 나면
뒤따르는 여러 태그가 같은 것을 재사용하기 때문이다.

배치마다 지킬 것도 함께 적었다 — 근거 줄번호, 이름 검사,
짐작 금지, 값보다 사유.

문서만 변경. 게이트는 앞 커밋 상태 그대로다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 11:34:34 +09:00
MsystechandClaude Opus 5 d63f6f6bc0 주소 4종을 옮긴다 — 결합 규칙이 셋 다 다르다
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>
2026-08-19 11:33:00 +09:00
MsystechandClaude Opus 5 510663c5fc 태그를 11종 → 19종으로. "전부"의 규모를 먼저 재 봤다
"나머지도 전부 옮기자" — 추측하지 않고 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>
2026-08-19 11:27:33 +09:00
MsystechandClaude Opus 5 222d1987d5 환자 검색·내원 조회를 재 보고 고친다 — 5초 두 곳이 각각 441ms·1ms 가 됐다
"검색 및 선택하면 시간이 너무 오래걸려" — 체감이 아니라 숫자였다.
진단에 시간을 재는 줄을 먼저 넣었다. 어디가 느린지 숫자 없이 고치면 엉뚱한 곳을 만진다.

## 실측 (--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>
2026-08-19 11:23:49 +09:00
MsystechandClaude Opus 5 4bdd583adb 주민번호 복호화를 붙인다 — 이 DB 는 실제로 암호화되어 있었다
문서(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>
2026-08-19 11:15:24 +09:00
MsystechandClaude Opus 5 34c53e3d69 주민번호 복호화 경로를 찾아 문서로 남긴다 — 앱에 키를 넣지 않아도 된다
환자 태그의 다음 관문. 성별(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>
2026-08-19 11:03:49 +09:00
MsystechandClaude Opus 5 128aedc34f 환자 태그 매핑을 레거시 함수 본문으로 다시 세운다 — 셋은 존재하지 않는 이름이었다
"환자 태그이지만 아직 옮기지 않았습니다 엄청 많은데?" — 맞다. 그리고
옮겼다고 적어 둔 것도 절반이 거짓이었다.

## 지어낸 이름 셋

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>
2026-08-19 10:56:42 +09:00
MsystechandClaude Opus 5 5ecc7b514a 환자 선택을 제목줄로 올린다 — 한 번 고르면 앱 전체가 그 환자를 본다
테마 버튼 오른쪽에 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>
2026-08-19 10:47:24 +09:00
MsystechandClaude Opus 5 1baf805f02 흐림의 진짜 원인은 TextFormattingMode 였다 — 색 번짐만 재서 두 번 놓쳤다
전역 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>
2026-08-19 10:41:43 +09:00
MsystechandClaude Opus 5 bbbc7f5a08 요약에서 환자를 두 번 적지 않고, 흐린 글씨를 표면 단위로 잡는다
## ① 요약이 환자를 두 번 적고 있었다

아래 버튼이 이미 "환자 해제 — 강한(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>
2026-08-19 10:36:38 +09:00
MsystechandClaude Opus 5 52f1e30437 쿼리 편집에서 환자를 고를 수 있게 하고, 카드 안 글씨를 다시 선명하게 만든다
"아직도 안됨, 그리고 글씨도 흐리게보이고" — 둘 다 맞다.

## ① 안 되는 게 아니라 고를 수가 없었다

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>
2026-08-19 10:16:32 +09:00
MsystechandClaude Opus 5 8964c662f3 검증 실행이 치환 변수를 지우던 것을 고친다 — 그 쿼리들이 통과할 수 없었다
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>
2026-08-19 10:08:07 +09:00
MsystechandClaude Opus 5 3ef1937902 '새 서식'을 없앤다 — 코드 없는 문서는 그린 뒤에 저장이 막힌다
서식은 항상 목록에서 골라 연다는 결정.

## 없앤 진입점 넷

- 파일 메뉴 '새 서식(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>
2026-08-19 09:27:58 +09:00
MsystechandClaude Opus 5 9bff3dd5f6 MDataTable 을 미리보기에서 실제로 돌린다 — 태그만 채우면 반쯤 빈 것을 못 본다
"태그와 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>
2026-08-19 09:08:40 +09:00
MsystechandClaude Opus 5 f50a7cff4b 환자 미리보기에서 부서 문을 뺀다 — 레거시에 없던 제약을 내가 붙였다
개발자 계정으로 환자 선택을 누르면 "전산실 소속만 가능합니다"가 떴다.
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>
2026-08-18 15:51:30 +09:00
MsystechandClaude Opus 5 88dab2edcf 환자를 골라 태그에 값이 들어간다 — 데이터 계층만 있던 것을 화면에 붙인다
전 커밋까지 환자 조회·시각 판정·권한 규칙은 다 있었지만 화면에 붙은 것이
하나도 없었다. 미리보기를 열면 환자 선택이 없고, 태그는 전부 사유만 찍혔다.

## 만든 사슬

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>
2026-08-18 15:13:48 +09:00
MsystechandClaude Opus 5 be9889ed4e 적용일시 판정을 표로 고정한다 — 한 칸 어긋나면 태그가 통째로 빈다
미리보기 레거시 호환 계획 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>
2026-08-18 11:43:34 +09:00
MsystechandClaude Opus 5 1f501df5f7 환자·내원 조회를 붙인다 — 레거시 SQL 을 그대로 옮기지 않고
미리보기 레거시 호환 계획 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>
2026-08-18 10:21:47 +09:00
MsystechandClaude Opus 5 e9d8f7356e 환자 미리보기 접근 규칙을 먼저 세운다 — 레거시에서 옮기지 않을 것들
미리보기 레거시 호환 계획의 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>
2026-08-18 10:09:10 +09:00
MsystechandClaude Opus 5 5072acb518 태그 사유가 맞아진다 — 'Inital' 은 오타가 아니라 실제 표기다
미리보기 레거시 호환 계획의 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>
2026-08-18 10:06:53 +09:00
MsystechandClaude Opus 5 26e93cae94 미리보기에 태그 값이 흐를 관을 판다 — 값 아니면 사유
미리보기 레거시 호환 계획의 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>
2026-08-18 10:04:49 +09:00
MsystechandClaude Opus 5 a67b6ee4b8 미리보기에 보기 전환을 둔다 — 인쇄될 것만 / 화면 그대로
미리보기 레거시 호환 계획의 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>
2026-08-18 09:54:00 +09:00
MsystechandClaude Opus 5 9e3cd088e2 미리보기가 편집을 따라오고, 창이 하나만 뜬다
미리보기 레거시 호환 계획의 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>
2026-08-18 09:42:19 +09:00
MsystechandClaude Opus 5 dbb3deeda3 컨테이너 안도 인쇄 필터를 탄다 — Panel 안 컨트롤이 '인쇄 출력'을 지킨다
미리보기 레거시 호환 계획의 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>
2026-08-18 09:32:27 +09:00
MsystechandClaude Opus 5 3af7b3a071 수정 경로를 실DB 로 돌려 본다 — 바꾸고, 확인하고, 원복한다
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>
2026-08-18 09:19:33 +09:00
MsystechandClaude Opus 5 ae2f8f2f61 '수정'을 되살린다 — 지금 쓰이는 버전을 제자리에서 고친다
레거시 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>
2026-08-17 21:17:55 +09:00
MsystechandClaude Opus 5 a386c5504b 열기·저장을 DB 로만 — 'DB에서 열기' 대화상자를 지우고 좌측 목록으로 모은다
<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>
2026-08-15 17:58:12 +09:00
MsystechandClaude Opus 5 2f5c9bf82d 간격을 숫자로 넣는다 — 4px 씩 12왕복에서 한 번으로
여기까지 간격을 정하는 길은 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>
2026-08-14 18:04:57 +09:00
MsystechandClaude Opus 5 6d06f0a4c8 태그 피커에서 재진술을 덜어낸다 — 우측 패널 4블록 삭제
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>
2026-08-14 17:59:00 +09:00
MsystechandClaude Opus 5 9b2ae0ea67 태그 행에서 모달 없이 태그를 고른다 — 인라인 완성
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>
2026-08-14 17:43:53 +09:00
MsystechandClaude Opus 5 a869e5a5bc 태그 검색이 사람 말을 받는다 — '사인'으로 서명 태그 17종이 나온다
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>
2026-08-14 17:35:41 +09:00
MsystechandClaude Opus 5 d72ab3ce1e 인스펙터가 손을 놓지 않는다, 그리고 저장 확인이 사람 말을 한다
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>
2026-08-14 17:30:11 +09:00
MsystechandClaude Opus 5 7e38ba654a 모달 인구를 줄이고 배치 명령에 손잡이를 붙인다
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>
2026-08-14 17:18:57 +09:00
MsystechandClaude Opus 5 d6052610b9 루프를 키보드로 닫는다 — Tab 다음, Shift+Tab 이전, Enter 안, Esc 밖
이 도구의 일은 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"의 반복인데,
<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>
2026-08-14 17:07:09 +09:00
MsystechandClaude Opus 5 1b49f07dd9 미리보기가 거짓말을 멈춘다, 그리고 9번째 컨트롤이 1번째를 덮지 않는다
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>
2026-08-14 17:01:46 +09:00
MsystechandClaude Opus 5 d55ca39e6a 운영 규모 예산을 잰다 — 그리고 2단계 근거가 사라졌다
재설계 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>
2026-08-14 16:55:33 +09:00
MsystechandClaude Opus 5 4150ebe1d7 메인 화면을 게이트에 넣고, 최소 크기를 단일 출처로, 창 크기를 기억한다
재설계 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&lt;string,bool&gt; 이라
숫자를 못 담던 것이 원인이었다. 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>
2026-08-14 16:50:24 +09:00
MsystechandClaude Opus 5 862657cf2f 글자가 잘리면 CI 가 실패하게 만든다 — 사람이 그림 57장을 보는 대신
재설계 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>
2026-08-14 16:33:57 +09:00
MsystechandClaude Opus 5 280594362a Effect·팝업이 ClearType 을 끄고 있었다 — 글자가 흐린 이유
사용자 지적이 맞았다. 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>
2026-08-14 16:11:23 +09:00
MsystechandClaude Opus 5 b0d2e9b470 가림막이 대화상자 위에 있었다 — z순서를 명시적으로 내린다
두 번째 오진이다. 지난번에는 가림막이 아예 안 깔렸고, 고친 뒤에는 <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>
2026-08-14 15:06:08 +09:00
MsystechandClaude Opus 5 7e0e201acb 태그 값 줄 — 값이 나오면 값을, 아니면 왜 안 나오는지를
앞 커밋의 판정표를 화면에 붙였다. 우측 정보 패널에 '이 태그의 값' 한 칸.

'미리보기'라 부르지 않는다. 실제로 값이 나오는 것은 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>
2026-08-14 15:03:01 +09:00
MsystechandClaude Opus 5 836a3e843a 태그마다 값을 만들 수 있는지, 없다면 왜인지 — 판정표
'예시 값' 을 진짜로 보여 주기로 했는데, 조사해 보니 서식생성기에서 값이 나오는 태그는
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>
2026-08-14 14:54:37 +09:00
MsystechandClaude Opus 5 b2bdc7fa41 태그 선택 — 목업 구조로. 다만 없는 정보는 만들지 않는다
1080x700 3단(분류 / 결과 / 태그 정보), 큰 제목 + 부제, 검색칩,
바닥에 선택 칩과 버튼. 목업의 뼈대를 그대로 가져왔다.

가져오지 않은 것과 그 이유:

· 설명 한 줄 — 384건 전부 없다. 원천(bzDataInterface)에도 주석이 0줄이라 사람이 써야 한다.
  LLM 으로 지어내면 안 된다: 태그가 실제로 무엇을 꺼내는지는 VB 함수 본문의 SQL 을 읽어야 알고,
  틀린 설명은 없는 설명보다 나쁘다.
· 예시 값 — 값은 런타임에 HIS DB 에서 환자 문맥을 받아 계산된다. '강남성심병원' 같은 더미를 넣으면
  사용자가 그것을 실제 반환값으로 믿는다. 실제 조회 가능 여부는 별도 조사 중이다.
· 최근 사용 일시 — 저장소가 없다. 만들어도 첫 실행에 384칸이 전부 빈다.
· 태그 키 절 — 태그는 이름이 곧 키다. 같은 문자열을 두 번 그리는 대신
  우측 큰 이름을 고정폭으로 두고 복사 버튼을 붙였다.
· 2단 소분류(기관·문서 > 기본 정보) — 데이터는 1단이다. 억지로 가르면 1개짜리 버킷 25개가 나오고,
  손으로 배당하면 새 태그가 자동 분류되지 않는 부채가 남는다.
· 상단 가로 칩 필터 줄 — 좌측 분류 트리와 같은 필터를 두 번 그린다. 두 곳이 갈라지면
  어느 쪽이 진실인지 알 수 없다. 필터의 단일 출처는 좌측 열이다.
· ⌘K 칩 — Windows 앱이고, 창이 열리면 생성자가 이미 검색칸에 포커스를 준다.

바꿔 넣은 것:

· '관련 필드' → '변형'. 없는 개념을 흉내내는 대신 지금 데이터로 계산되는 진짜 관계를 쓴다 —
  마지막 밑줄 앞이 같은 태그들(PAT_주민번호 → _Dash · _Blind). TagSearch.VariantsOf 신설.
  밑줄이 하나뿐이면 그건 접두어 경계라 제외한다(안 그러면 PAT_ 전체가 변형으로 딸려 온다).
· '자주 사용' 배지 → '추천'. 그런 임계값은 코드에 없다. 있는 것은 컨트롤 타입별 큐레이션
  (LegacyTagUsageCatalog.SuggestFor)뿐이라 '자주 사용'이라 부르면 근거를 왜곡한다.
· 사용 횟수에 스냅샷 표기를 붙였다. 숫자만 크게 띄우면 실시간 집계로 읽힌다.
· 상단 칩 줄 자리에는 좌측과 겹치지 않는 것 하나만 — '미사용 숨기기'.
  384종 중 227종이 미사용이라 값이 크다. 분류와 겹쳐서 걸린다.

창 껍데기는 OS 크롬을 유지했다. 목업의 '오른쪽 위 X + 둥근 카드'는 WindowStyle=None 이 필요한데
이 앱 대화상자 10종이 전부 OS 크롬이라 이 창만 바꾸면 규약이 두 벌이 되고,
AllowsTransparency 는 ClearType 이 꺼져 13px 한글이 뭉개져 금지돼 있다.
둥근 모서리와 그림자는 DWM 이 이미 그린다.

게이트: 테스트 267/267, --edit-smoke 0실패, --modal-check 2/2, --maxrect 10/10,
--query-popup 17/17, --db-smoke 3000 diff 0·예외 0,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --dialog-shots 57파일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:32:09 +09:00
MsystechandClaude Opus 5 6ae05b73cd 가림막이 실제로는 안 깔리고 있었다 — 판정 시점을 고친다
직전 커밋(d9c1eaa)은 코드만 멀쩡했고 화면에는 아무 일도 일어나지 않았다.

ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다) <b>그다음에</b> 모달 루프에 들어간다.
그래서 Loaded 에서 읽는 ComponentDispatcher.IsThreadModal 은 아직 false 다 —
가드에 걸려 매번 그냥 반환했다. 모달 프레임이 돌기 시작한 뒤로 판정을 미룬다.
한 프레임 늦게 깔리는 것은 눈에 띄지 않는다.

이런 종류는 코드를 다시 읽어서는 안 잡힌다. 열어 봐야 안다.
그래서 --modal-check 를 만들었다: 대화상자를 실제로 ShowDialog 로 띄우고
그 순간 가림막 창이 화면에 존재하는지 센 뒤 닫는다.

이 진단을 만들면서 두 번 헛다리를 짚었고, 둘 다 검사가 스스로를 속인 경우다.
· BeginInvoke 로 세면 그 호출이 Loaded 보다 먼저 큐에 들어가 가림막을 거는 콜백보다
  앞서 실행된다 — 항상 0 이 나온다. 타이머로 바꿨다.
· 진단 분기는 OnStartup 의 전역 배선보다 먼저 반환하므로 진단 모드에는 그 훅이 없다.
  검사가 같은 배선을 직접 걸어야 프로덕션과 같은 것을 잰다.

실측: Loaded 시점 IsThreadModal=False / 모달 루프 안 True / 떠 있는 동안 가림막 1개 / 닫으면 0개.

게이트: 테스트 267/267, --edit-smoke 0실패, --maxrect 10/10, --dialog-shots 57파일,
--db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일, --modal-check 2/2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 14:20:02 +09:00
MsystechandClaude Opus 5 d9c1eaa293 모달 뒤를 어둡게 — 대화상자가 떠 있다는 것이 보이게
WPF 의 ShowDialog 는 입력만 막고 화면은 그대로 둔다. 대화상자가 앱과 같은 밝기로 떠 있으면
"이게 떠 있는 동안은 뒤를 못 만진다"가 안 읽혀서, 뒤를 클릭했다가 아무 반응이 없으면
멈춘 것으로 오해한다.

창이 뜨는 순간을 전역으로 잡아 모달일 때만 반투명 검정 창을 뒤에 깐다.
호출부마다 고치지 않은 이유는 ShowDialog 가 17곳이고 앞으로 더 늘기 때문이다 —
한 곳이라도 빠지면 그 대화상자만 다르게 동작하는데 그건 눈으로 전수 확인해야만 알 수 있다.
제목 표시줄 색을 거는 자리(WindowChromeTheme)와 같은 곳에 나란히 걸었다.

모달 판정은 ComponentDispatcher.IsThreadModal 이다. 진단 모드는 창을 모달이 아니라
화면 밖에 그냥 띄우므로 이 경로를 안 탄다 — --dialog-shots 57파일 그대로다.

가림막은 뒤 창의 이동·크기·상태 변화를 따라간다. 안 따라가면 창을 옮겼을 때
어두운 사각형만 제자리에 남는다. 최대화된 창은 Left/Top 이 '복원했을 때의 값'이라
그대로 쓰면 엉뚱한 데 깔리므로 화면 좌표를 직접 물어 DIP 로 되돌린다.

대화상자가 겹쳐 뜨면 그만큼 쌓이고, 각자 닫힐 때 제 것만 걷는다.

게이트: 테스트 267/267, --edit-smoke 0실패, --query-popup 17/17, --maxrect 10/10,
--db-smoke 3000 diff 0·예외 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일,
--dialog-shots 57파일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-14 13:48:56 +09:00
122 changed files with 22293 additions and 504 deletions
+114
View File
@@ -0,0 +1,114 @@
# CTX 통합 계획 — 실제 순증은 48종이다
검증 통과 태그를 SheetMe 현행 구현과 대조한 결과(에이전트 6, 73만 토큰).
| 상태 | 수 | 뜻 |
| --- | --- | --- |
| new | **48** | 순증 — 값 생성 코드가 없다 |
| already | 13 | 현행이 같은 값을 낸다 — 건드리지 않는다 |
| conflict | **6** | 처리하지만 값 규칙이 다르다 — 고쳐야 한다 |
| skip | 1 | 죽은 코드 |
즉 통합하면 <b>26종 → 74종</b>이 되고, 그와 별개로 6건을 고쳐야 한다.
## 내가 지난 커밋에 적은 것 하나가 틀렸다
`WORKFLOW-CTX-ROUND1.md``ETC_로그인_사용자명``NVL(UidNam,' ')` 때문에
레거시와 갈린다고 적었다. **틀렸다.** `UserContextStore.cs:80``.Trim()` 이 그 공백을
도로 걷어내서 NULL 케이스는 양쪽 다 `""` 다. 검증이 코드를 직접 읽고 반박했다.
남는 실차이는 다른 것이다 — <b>SheetMe 만 Trim 한다</b>(레거시 16713 은 안 한다).
## conflict 6건 — 고칠 것
| 태그 | 어떻게 다른가 |
| --- | --- |
| `ETC_로그인_사용자ID_사용자명` | 레거시는 <b>항상</b> `ID.이름` 결합. SheetMe(`TagPreviewCatalog.cs:197`)는 둘 다 비어 있지 않을 때만 |
| `ETC_로그인_사용자ID` | 유효기간 기준일 — 레거시 `GetAdpDtm` 앞 8자, SheetMe `SYSDATE`. 그리고 SheetMe 만 Trim |
| `ETC_로그인_사용자명` | SheetMe 만 Trim / 기준일 차이 |
| `ETC_로그인_사용자연락처` | 레거시는 유효기간으로 고른 행, SheetMe(`TagPreviewStore.cs:83`)는 `WHERE UIDCOD` 만으로 첫 행 |
| `ETC_로그인_원내연락처` | 같은 조회를 공유하므로 같은 결함 |
| `ETC_현재년도` | 레거시는 <b>"2026년"</b>(접미 "년"), SheetMe 는 "2026" |
`ETC_현재년도` 는 앞선 문서에 없던 <b>네 번째 차이</b>다.
고칠 때 `tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs:108` 이 "2026" 을 단정하고 있어
그 판정도 함께 바뀌어야 한다 — 검증이 짚어 줬다.
## 통합 순서
1. conflict 6건 수정 + 관련 테스트 갱신
2. new 48종을 배치로 통합(배치마다 `--edit-smoke` 이름 검사 · `--db-patient` · md5)
3. already 13종은 건드리지 않는다
## 태그별 판정
| 상태 | 태그 |
| --- | --- |
| already | `ETC_로그인_사용자연락처` |
| already | `ETC_로그인_원내연락처` |
| already | `ETC_현재시간` |
| already | `ETC_현재일시_1_년` |
| already | `ETC_현재일시_2_월` |
| already | `ETC_현재일시_3_일` |
| already | `ETC_현재일시_4_몇시` |
| already | `ETC_현재일시_5_몇분` |
| already | `ETC_현재일시_MaskBox` |
| already | `ETC_현재일시` |
| already | `ETC_현재일자_영문` |
| already | `ETC_현재일자` |
| already | `OCM_수술일자_Refer` |
| conflict | `ETC_로그인_사용자ID_사용자명` |
| conflict | `ETC_로그인_사용자ID` |
| conflict | `ETC_로그인_사용자명` |
| conflict | `ETC_로그인_사용자연락처` |
| conflict | `ETC_로그인_원내연락처` |
| conflict | `ETC_현재년도` |
| new | `ETC_로그인사용자_서명` |
| new | `OCM_OPNAME` |
| new | `OCM_STFDTR` |
| new | `OCM_User진료과_Refer` |
| new | `OCM_User진료과_한글명칭` |
| new | `OCM_User진료과` |
| new | `OCM_User진료의사명_Refer` |
| new | `OCM_User진료의사명` |
| new | `OCM_내원유형` |
| new | `OCM_담당의사_Refer` |
| new | `OCM_담당의사_영문_Refer` |
| new | `OCM_담당의사_영문` |
| new | `OCM_담당의사면허번호_Refer` |
| new | `OCM_담당의사면허번호` |
| new | `OCM_담당의사싸인_Refer` |
| new | `OCM_담당의사싸인` |
| new | `OCM_담당의사전문의번호_Refer` |
| new | `OCM_담당의사전문의번호` |
| new | `OCM_로그인_사용자싸인` |
| new | `OCM_병동_Refer` |
| new | `OCM_병동_병실_베드` |
| new | `OCM_병동_병실` |
| new | `OCM_병동` |
| new | `OCM_병실베드` |
| new | `OCM_부상병POA_Refer` |
| new | `OCM_부상병POA` |
| new | `OCM_부상병명_Refer` |
| new | `OCM_부상병코드_Refer` |
| new | `OCM_부상병코드` |
| new | `OCM_상병명_가로_Refer` |
| new | `OCM_상병명_가로` |
| new | `OCM_상병명_영어_가로_Refer` |
| new | `OCM_상병명_영어_가로` |
| new | `OCM_상병명_영어_세로_Refer` |
| new | `OCM_상병명_영어_세로` |
| new | `OCM_상병코드_가로_Refer` |
| new | `OCM_상병코드_가로` |
| new | `OCM_상병코드_세로_Refer` |
| new | `OCM_상병코드_세로` |
| new | `OCM_수술_OprPatETC` |
| new | `OCM_수술부위` |
| new | `OCM_수술상병` |
| new | `OCM_수술주상병` |
| new | `OCM_심사_부상병명` |
| new | `OCM_심사_부상병코드` |
| new | `OCM_심사_주상병명` |
| new | `OCM_심사_주상병코드` |
| new | `OCM_외래내원일시_Refer` |
| skip | `ETC_건강보험증번호_Refer` |
+121
View File
@@ -0,0 +1,121 @@
# 주민번호 복호화 경로 — 조사 결과
환자 태그의 다음 관문이다. `PAT_성별`·`PAT_한글성별`(277건)과 `PAT_나이` 계열(204건)이
전부 **복호화된 주민번호**를 선행 조건으로 삼는다.
조사 대상은 읽기 전용 레거시(`C:\MsystechHIS_Ver.2`)이고, 아래 줄 번호는 그 스냅샷 기준이다.
## 결론 먼저 — 앱에 키를 넣지 않아도 된다
복호화는 **DB 안에서** 일어난다. 클라이언트는 암호문을 넘기고 평문을 받는다.
```
dtCommonLib.vb:6960 GetDecResNum(psEncResNum)
```
```sql
-- 기본
SELECT SUBSTR(:psEncResNum,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:psEncResNum,8,256)) DecResNum
FROM DUAL
-- 병원 SCHILDREN 만(2023-10-14 DAMO 로 변경)
SELECT UDF_GETDECRESNUM_damo(:psEncResNum) DecResNum FROM DUAL
```
바인드 변수를 쓴다. 키는 오라클 패키지 `CRYPTO_AES256` 안에 있고 접근 통제는 **DB 권한**이다 —
그것이 있어야 할 자리다. **앱은 키를 갖지 않는다.** 이 성질을 깨는 이식(키를 상수로 옮기는 등)은 하지 않는다.
앞 7자리는 평문으로 두고 8자리부터만 암호화되어 있다는 점에 주의 —
`SUBSTR(...,1,7)` 을 그대로 이어 붙인다.
## 어떤 병원에서 켜지는가
```
clsCommonLib.vb:4847-4848 HspCfg.PersonalDataEnc / PersonalDataEnc_ResNum
clsCommonLib.vb:5835-5841 적재
```
두 단이다.
1. `M_DtlMst` (DtlTblCod='HSPCFG', DtlCod='PersonalDataEnc') → `DtlCodVal``=` 앞 조각이 Y/N
2. 그것이 `Y` 일 때만 `M_DtsMst` (DtsTabCod='HSPCFG', DtlCod='PersonalDataEnc', DtsCod='ResNum')
`DtsCodVal` 이 주민번호 개별 Y/N (`GetDtsCodVal`, :7302)
`N` 이면 `P_PatInf.PatResNum` 이 **평문**이다. `Y` 면 그 컬럼은 쓰지 말고 `PatResEnc` 를 복호화해야 한다.
> 지금 SheetMe 의 `PatientTagResolver` 가 `PAT_주민번호` 를 뺀 이유가 이것이다.
> `PatResNum` 을 그대로 쓰면 암호화 병원에서 **암호문이 종이에 찍힌다**.
## 복호화 뒤에 붙는 보정 (bzPatientInfo.vb:546-580)
평문을 얻은 뒤에도 그대로 쓰지 않는다.
- **SRCH · JINJU**: 주민번호에 기관기호가 들어간 경우가 있다(예 `9750012203265`).
`Substring(1,6)` 이 날짜로 해석되지 않으면 `PatBthDay` 뒤 6자리로 앞자리를 갈아 끼운다.
**이 개발 DB 가 SRCH 다 — 이 갈래가 살아 있다.**
- **외국인 보정**: 7번째 자리가 `0` 또는 `9` 이고 `PatNatCod` 가 비어 있지 않으면,
`PatBthDay` 앞 2자리가 `19` 인지에 따라 그 자리를 `6/8`(0인 경우) 또는 `5/7`(9인 경우)로 바꾼다.
둘 다 `PatBthDay`·`PatNatCod` 만 쓰는 **순수 문자열 계산**이라 그대로 옮기고 표로 고정할 수 있다.
## 성별 — 지금 옮길 수 있다
```
clsCommonLib.vb:613 GetSexAge(psResNum, ByRef psSex, ByRef psAge)
```
암호화 여부로 갈라지지만 **두 갈래의 본문이 같다**. 성별은 순수 계산이다.
```
7번째 자리 mod 2 == 0 → "F", 아니면 "M"
```
`PAT_한글성별` 은 거기에 F→"여", M→"남" 을 붙인다(bzDataInterface.vb:305-316).
`PAT_성별` 에는 앞 분기가 하나 더 있다 —
`CoiCalCod``"24"` 로 시작하면 주민번호를 보지 않고 `PatSexTyp` 을 쓴다(:283-291).
`CoiCalCod``P_CoiInf` 에 있으므로 이미 문맥에 들어와 있다.
## 나이 — 별도 작업이 필요하다 (지금 옮기지 말 것)
```
clsCommonLib.vb:735 AgeCheck(psResNum, psToDate, psInsCod)
```
7번째 자리로 세기를 정하는데 갈래가 많고 역사적 예외가 붙어 있다.
| 7번째 자리 | 처리 |
| --- | --- |
| 1, 2 | `19` + 주민번호 |
| 3, 4 | `20` + 주민번호 |
| 5, 6 | 앞 2자리 < 20 이면 2000년대 보호기관 영아, 아니면 1900년대 외국인. **REDCROSS 는 서버 연도와 비교하는 별도 규칙** |
| 7, 8 | 의료급여 전산관리번호(행려환자·시설입소자) 규약 |
`psInsCod`(보험코드)까지 결과를 바꾸고, 1901~1919년생 외국인과 2001년 이후 보호기관 영아는
**원리상 구분이 불가능하다**고 원본 주석이 명시한다(20080821).
절반만 옮기면 종이에 그럴듯한 나이가 찍힌다. 그건 빈칸보다 나쁘다 —
아무도 의심하지 않기 때문이다. 사례 표를 먼저 만들고 한 갈래씩 고정하는 별도 작업으로 둔다.
## 개인정보 접근 로그
레거시에 전용 기록 경로가 있다.
```
clsCommonLib.vb:28279 WriteLogPersonalDataAccess(chtNum, prcNam, dataTyp, comNum) → C_PdrInf
```
SheetMe 는 지금 파일 로그(`AppLog.Audit`)에 `[환자 미리보기] 사용자=… 내원=…` 만 남긴다
(환자 이름은 남기지 않는다). 주민번호를 복호화하기 시작하면 **가장 민감한 필드**를 다루는 것이므로
기록 범위를 정해야 한다. `C_PdrInf` 에 쓰는 것은 운영 DB 쓰기이므로 사용자 승인이 필요하다 —
정하기 전에는 파일 로그에 "주민번호 복호화" 사실만 남기는 쪽이 안전하다.
## 작업 순서 제안
1. `Core`: `ResidentNumber` 순수 함수 — 보정(SRCH/JINJU·외국인) + 성별. 사례 표로 고정.
2. `Data`: 암호화 플래그 2단 조회 + `DUAL` 복호화(바인드). 병원 갈래(SCHILDREN) 포함.
3. `Designer`: `PAT_주민번호`·`PAT_주민번호_Dash`·`PAT_성별`·`PAT_한글성별` 매핑.
`PAT_성별``CoiCalCod` 선행 분기를 함께.
4. 게이트: 실DB 로 "암호화 병원에서 평문이 나오는가"를 확인.
**값은 리포트에 쓰지 않는다** — 길이와 형식만 적는다.
5. 나이는 별도 작업.
+189
View File
@@ -0,0 +1,189 @@
# 문맥만 읽는 태그 48종 — 기계적으로 옮길 수 있는 목록
`bzDataInterface.vb` 386종을 본문 모양으로 분류한 결과:
| 갈래 | 수 | 성격 |
| --- | --- | --- |
| 자기 SQL 을 든 것 | 232 | 각각 별도 이식 — 본문 SQL 을 읽고 바인드로 옮기고 실DB 로 확인해야 한다 |
| MessageBox 를 띄우는 것 | 48 | 대화형이라 미리보기에서 <b>돌 수 없다</b> |
| 문맥만 읽는 것 | 48 | <b>여기가 옮길 수 있는 것</b> (11종 완료) |
| 기타 | 55 | 다른 라이브러리 호출·계산 — 하나씩 봐야 한다 |
| HspCod 로 갈리는 것 | 3 | 병원별 분기 |
아래는 문맥만 읽는 48종의 본문이다. 옮길 때마다 이름이 실제 태그인지
`--edit-smoke` 의 이름 검사가 확인한다(지어낸 이름 셋이 조용히 앉아 있던 적이 있다).
```
PAT_차트번호 Return moPatInfoBiz.ChtNum
PAT_이름 Return moPatInfoBiz.PatInfDR.Item("PatNam").ToString
PAT_영문이름 Return moPatInfoBiz.PatInfDR.Item("PatEngNam").ToString
PAT_생년월일 Dim sBirthDay As String = "" Dim sBthDay As String sBthDay = moPatInfoBiz.PatInfDR.Item("PatBthDay").ToString sBirthDay += sBthDay.Substring(0, 4) & "-" & sBthDay.Substring(4, 2) & "-" & sBthDay.Substring(6, 2) Return sBirthDay
PAT_생년월일_영문 Dim sBirthDay As String = "" Dim sBthDay As String sBthDay = moPatInfoBiz.PatInfDR.Item("PatBthDay").ToString sBirthDay += sBthDay.Substring(4, 2) & "-" & sBthDay.Substring(6, 2) & "-" & sBthDay.Substring(0, 4) Return sBirthDay
PAT_생년월일_영문_일월년도 Dim sBirthDay As String = "" Dim sBthDay As String sBthDay = moPatInfoBiz.PatInfDR.Item("PatBthDay").ToString sBirthDay += sBthDay.Substring(6, 2) & "-" & sBthDay.Substring(4, 2) & "-" & sBthDay.Substring(0, 4) Return sBirthDay
PAT_우편번호 Dim sZipBdg As String = "" sZipBdg = moPatInfoBiz.PatInfDR.Item("PatZipBdg").ToString Dim oZipInfoDR As DataRow = clsCommonLib.GetInstance.GetZipInfoByBuildingNumber_Simple(sZipBdg) If oZipInfoDR IsNot Nothing Then Return oZipInfoDR.Item("구역번호").ToString Else Return "" End If
PAT_도로명주소 Dim sZipBdg As String = "" sZipBdg = moPatInfoBiz.PatInfDR.Item("PatZipBdg").ToString Dim oZipInfoDR As DataRow = clsCommonLib.GetInstance.GetZipInfoByBuildingNumber_Simple(sZipBdg) If oZipInfoDR IsNot Nothing Then Return oZipInfoDR.Item("도로명주소").ToString & " " & moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString Else Return moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString End If
PAT_지번주소 Dim sZipBdg As String = "" sZipBdg = moPatInfoBiz.PatInfDR.Item("PatZipBdg").ToString Dim oZipInfoDR As DataRow = clsCommonLib.GetInstance.GetZipInfoByBuildingNumber_Simple(sZipBdg) If oZipInfoDR IsNot Nothing Then Return oZipInfoDR.Item("지번주소").ToString & moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString Else Return moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString End If
PAT_영문도로명주소 Dim sZipBdg As String = "" sZipBdg = moPatInfoBiz.PatInfDR.Item("PatZipBdg").ToString Dim oZipInfoDR As DataRow = clsCommonLib.GetInstance.GetZipInfoByBuildingNumber_Simple(sZipBdg) If oZipInfoDR IsNot Nothing Then Return moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString & " " & oZipInfoDR.Item("영문도로명주소").ToString Else Return moPatInfoBiz.PatInfDR.Item("PatAdrDtl").ToString End If
PAT_휴대전화번호 Return moPatInfoBiz.PatInfDR.Item("PatMblPhn").ToString
PAT_휴대전화번호_뒷4자리 Dim sMblPhn As String = moPatInfoBiz.PatInfDR.Item("PatMblPhn") & "" If sMblPhn & "" <> "" AndAlso sMblPhn.Length >= 4 Then Return Right(sMblPhn, 4) Else Return "" End If
PAT_자택전화번호 Return moPatInfoBiz.PatInfDR.Item("PatHomPhn").ToString
PAT_연락처 If moPatInfoBiz.PatInfDR.Item("PatMblPhn").ToString.Trim <> "" Then Return moPatInfoBiz.PatInfDR.Item("PatMblPhn").ToString Else Return moPatInfoBiz.PatInfDR.Item("PatHomPhn").ToString End If
PAT_연락처_뒷4자리 Dim sPhoneNum As String = "" If moPatInfoBiz.PatInfDR.Item("PatMblPhn").ToString.Trim <> "" Then sPhoneNum = moPatInfoBiz.PatInfDR.Item("PatMblPhn").ToString.Replace("-", "").Replace(".", "") & "" sPhoneNum = Strings.Right(sPhoneNum, 4) Return sPhoneNum Else sPhoneNum = moPatInfoBiz.PatInfDR.Item("PatHomPhn").ToString.Replace("-", "").Replace(".", "") & "" sPhoneNum = Strings.Right(sPhoneNum, 4) Return sPhoneNum End If
PAT_여권번호 Return moPatInfoBiz.PatInfDR.Item("PatPspNum").ToString
OCM_외래내원일시 Return 외래내원일시(moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12))
OCM_외래내원일자 Return clsCommonLib.GetInstance.Data2Format(moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8), "9999-99-99")
OCM_외래내원일자_영문 Return clsCommonLib.GetInstance.Data2Format_ENG(moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8), "MM-DD-YYYY")
OCM_입원전환일자 Return clsCommonLib.GetInstance.Data2Format(moPatInfoBiz.ComInfDR.Item("ComTrsDtm").ToString.Substring(0, 8), "9999-99-99")
OCM_입원전환일시 Return clsCommonLib.GetInstance.Data2Format(moPatInfoBiz.ComInfDR.Item("ComTrsDtm").ToString, "9999-99-99 99:99")
OCM_담당의사 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim Else Return "" End If
OCM_담당의사_담당의사코드 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim & "(" & oDR.Item("UidCod").ToString.Trim & ")" Else Return "" End If
OCM_담당의사_연락처 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidMblPhn").ToString.Trim Else Return "" End If
OCM_STFDTR Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If If oCodInfDT.Rows.Count > 0 Then If oCodInfDT.Rows(0).Item("CodStfDtr") & "" <> "" Then oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodStfDtr"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) ' moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else Return "" End If Else Return "" End If Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) If oCodInfDT.Rows.Count > 0 Then If oCodInfDT.Rows(0).Item("CodStfDtr") & "" <> "" Then oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodStfDtr"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else Return "" End If Else Return "" End If End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim Else Return "" End If
OCM_담당의사_영문 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidEngNam").ToString.Trim Else Return "" End If
OCM_담당의사면허번호 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidLicNum").ToString.Trim Else Return "" End If
OCM_담당의사전문의번호 Dim oDR As DataRow Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidSpcLic").ToString.Trim Else Return "" End If
OCM_담당의사싸인 Dim oDR As DataRow Dim sRemotePath As String = "" Dim sLocalPath As String = "" Dim sDownLoadFolder As String = clsCommonLib.GetInstance.GetInstallPathFromServerInfoFile & "\Tmp\DownLoadedFiles\Signature\" Dim oFileHashtable As New Hashtable If Directory.Exists(sDownLoadFolder) = False Then CMM.CommonLibrary.clsCommonLib.GetInstance.CreateDirectory(sDownLoadFolder) End If Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then If oDR.Item("UidImgPth") IsNot Nothing AndAlso oDR.Item("UidImgPth").ToString <> "" Then For iLoop As Integer = 0 To 4 sRemotePath = "" sLocalPath = "" sRemotePath = oDR.Item("UidImgPth").ToString & "|" sLocalPath = sDownLoadFolder & oDR.Item("UidImgPth").ToString.Split("\").Last & "|" sRemotePath = sRemotePath.Substring(0, Len(sRemotePath) - 1) sLocalPath = sLocalPath.Substring(0, Len(sLocalPath) - 1) If moFileTransfer Is Nothing Then moFileTransfer = New M.EMR.FileTransfer.clsFileTransfer If moFileTransfer.DownLoadFile(1, sRemotePath, sLocalPath) = False Then MsgBox("파일 가져오기 실패") End If oFileHashtable.Add(sRemotePath, sLocalPath) If oFileHashtable IsNot Nothing AndAlso sRemotePath <> "" Then Exit For End If Next Return sLocalPath Else Return "" End If Else Return "" End If
OCM_진단서의사명 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidNam") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim Else Return "" End If End If
OCM_진단서의사명_외래 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidNam") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then oDR = Nothing Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim Else Return "" End If End If
OCM_진단서의사명_Refer Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidNam") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp_Refer = "I" Then If moPatInfoBiz.ComInfDR_Refer.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidNam").ToString.Trim Else Return "" End If End If
OCM_진단서의사_영문 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidEngNam") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidEngNam").ToString.Trim Else Return "" End If End If
OCM_진단서의사_영문_Refer Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidEngNam") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp_Refer = "I" Then If moPatInfoBiz.ComInfDR_Refer.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidEngNam").ToString.Trim Else Return "" End If End If
OCM_진단서의사면허번호 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidLicNum") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidLicNum").ToString.Trim Else Return "" End If End If
OCM_진단서의사면허번호_Refer Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidLicNum") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp_Refer = "I" Then If moPatInfoBiz.ComInfDR_Refer.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidLicNum").ToString.Trim Else Return "" End If End If
OCM_진단서의사전문의번호 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidSpcLic") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidSpcLic").ToString.Trim Else Return "" End If End If
OCM_진단서의사세부전문의번호 Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidDtlSpcLic") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidDtlSpcLic").ToString.Trim Else Return "" End If End If
OCM_진단서의사전문의번호_Refer Dim oDR As DataRow If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then Return moWrkInfoBiz.UidMst.Item("UidSpcLic") Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp_Refer = "I" Then If moPatInfoBiz.ComInfDR_Refer.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then Return oDR.Item("UidSpcLic").ToString.Trim Else Return "" End If End If
OCM_진단서의사싸인 Dim oDR As DataRow Dim sRemotePath As String = "" Dim sLocalPath As String = "" Dim sDownLoadFolder As String = clsCommonLib.GetInstance.GetInstallPathFromServerInfoFile & "\Tmp\DownLoadedFiles\Signature\" Dim oFileHashtable As New Hashtable If Directory.Exists(sDownLoadFolder) = False Then CMM.CommonLibrary.clsCommonLib.GetInstance.CreateDirectory(sDownLoadFolder) End If If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then If moWrkInfoBiz.UidMst.Item("UidImgPth") IsNot Nothing AndAlso moWrkInfoBiz.UidMst.Item("UidImgPth").ToString <> "" Then For iLoop As Integer = 0 To 4 sRemotePath = "" sLocalPath = "" sRemotePath = moWrkInfoBiz.UidMst.Item("UidImgPth").ToString & "|" sLocalPath = sDownLoadFolder & moWrkInfoBiz.UidMst.Item("UidImgPth").ToString.Split("\").Last & "|" sRemotePath = sRemotePath.Substring(0, Len(sRemotePath) - 1) sLocalPath = sLocalPath.Substring(0, Len(sLocalPath) - 1) If moFileTransfer Is Nothing Then moFileTransfer = New M.EMR.FileTransfer.clsFileTransfer If moFileTransfer.DownLoadFile(1, sRemotePath, sLocalPath) = False Then MsgBox("파일 가져오기 실패") End If oFileHashtable.Add(sRemotePath, sLocalPath) If oFileHashtable IsNot Nothing AndAlso sRemotePath <> "" Then Exit For End If Next If sLocalPath.Trim = "" Then MsgBox("의사싸인을 가져오는데 오류가 발생했습니다. 전산실에 문의주세요.") End If Return sLocalPath Else Return "" End If Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp = "I" Then If moPatInfoBiz.ComInfDR.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum, moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then If oDR.Item("UidImgPth") IsNot Nothing AndAlso oDR.Item("UidImgPth").ToString <> "" Then For iLoop As Integer = 0 To 4 sRemotePath = "" sLocalPath = "" sRemotePath = oDR.Item("UidImgPth").ToString & "|" sLocalPath = sDownLoadFolder & oDR.Item("UidImgPth").ToString.Split("\").Last & "|" sRemotePath = sRemotePath.Substring(0, Len(sRemotePath) - 1) sLocalPath = sLocalPath.Substring(0, Len(sLocalPath) - 1) If moFileTransfer Is Nothing Then moFileTransfer = New M.EMR.FileTransfer.clsFileTransfer If moFileTransfer.DownLoadFile(1, sRemotePath, sLocalPath) = False Then MsgBox("파일 가져오기 실패") End If oFileHashtable.Add(sRemotePath, sLocalPath) If oFileHashtable IsNot Nothing AndAlso sRemotePath <> "" Then Exit For End If Next If sLocalPath.Trim = "" Then MsgBox("의사싸인을 가져오는데 오류가 발생했습니다. 전산실에 문의주세요.") End If Return sLocalPath Else Return "" End If Else Return "" End If End If
OCM_진단서의사싸인_Refer Dim oDR As DataRow Dim sRemotePath As String = "" Dim sLocalPath As String = "" Dim sDownLoadFolder As String = clsCommonLib.GetInstance.GetInstallPathFromServerInfoFile & "\Tmp\DownLoadedFiles\Signature\" Dim oFileHashtable As New Hashtable If Directory.Exists(sDownLoadFolder) = False Then CMM.CommonLibrary.clsCommonLib.GetInstance.CreateDirectory(sDownLoadFolder) End If If moWrkInfoBiz.UidMst.Item("UidDtrYon").ToString.Trim = "Y" Then If moWrkInfoBiz.UidMst.Item("UidImgPth") IsNot Nothing AndAlso moWrkInfoBiz.UidMst.Item("UidImgPth").ToString <> "" Then For iLoop As Integer = 0 To 4 sRemotePath = "" sLocalPath = "" sRemotePath = moWrkInfoBiz.UidMst.Item("UidImgPth").ToString & "|" sLocalPath = sDownLoadFolder & moWrkInfoBiz.UidMst.Item("UidImgPth").ToString.Split("\").Last & "|" sRemotePath = sRemotePath.Substring(0, Len(sRemotePath) - 1) sLocalPath = sLocalPath.Substring(0, Len(sLocalPath) - 1) If moFileTransfer Is Nothing Then moFileTransfer = New M.EMR.FileTransfer.clsFileTransfer If moFileTransfer.DownLoadFile(1, sRemotePath, sLocalPath) = False Then MsgBox("파일 가져오기 실패") End If oFileHashtable.Add(sRemotePath, sLocalPath) If oFileHashtable IsNot Nothing AndAlso sRemotePath <> "" Then Exit For End If Next If sLocalPath.Trim = "" Then MsgBox("의사싸인을 가져오는데 오류가 발생했습니다. 전산실에 문의주세요.") End If Return sLocalPath Else Return "" End If Else Dim oCodInfDT As DataTable If moPatInfoBiz.PatTyp_Refer = "I" Then If moPatInfoBiz.ComInfDR_Refer.Item("ComPrgStt").ToString = "ILV" Then oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComLevDtm").ToString.Substring(0, 12), 1) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, clsCommonLib.GetInstance.SystemDateTime, 1) End If oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), oCodInfDT.Rows(0).Item("CodEndDtm").ToString.Substring(0, 8)) 'moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) Else oCodInfDT = clsCommonLib.GetInstance.GetCodInfDT(moPatInfoBiz.ComNum_Refer, moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 12), 1) oDR = clsCommonLib.GetInstance.GetUidMst(oCodInfDT.Rows(0).Item("CodDtrCod"), moPatInfoBiz.ComInfDR_Refer.Item("ComAcpDtm").ToString.Substring(0, 8)) End If If oDR IsNot Nothing Then If oDR.Item("UidImgPth") IsNot Nothing AndAlso oDR.Item("UidImgPth").ToString <> "" Then For iLoop As Integer = 0 To 4 sRemotePath = "" sLocalPath = "" sRemotePath = oDR.Item("UidImgPth").ToString & "|" sLocalPath = sDownLoadFolder & oDR.Item("UidImgPth").ToString.Split("\").Last & "|" sRemotePath = sRemotePath.Substring(0, Len(sRemotePath) - 1) sLocalPath = sLocalPath.Substring(0, Len(sLocalPath) - 1) If moFileTransfer Is Nothing Then moFileTransfer = New M.EMR.FileTransfer.clsFileTransfer If moFileTransfer.DownLoadFile(1, sRemotePath, sLocalPath) = False Then MsgBox("파일 가져오기 실패") End If oFileHashtable.Add(sRemotePath, sLocalPath) If oFileHashtable IsNot Nothing AndAlso sRemotePath <> "" Then Exit For End If Next If sLocalPath.Trim = "" Then MsgBox("의사싸인을 가져오는데 오류가 발생했습니다. 전산실에 문의주세요.") End If Return sLocalPath Else Return "" End If Else Return "" End If End If
OCM_전과일시 Return clsCommonLib.GetInstance.Data2Format(moPatInfoBiz.CodInfDR.Item("CodStrDtm").ToString.Substring(0, 12), "9999-99-99 99:99")
OCM_전과일자 Return clsCommonLib.GetInstance.Data2Format(moPatInfoBiz.CodInfDR.Item("CodStrDtm").ToString.Substring(0, 8), "9999-99-99")
ETC_로그인_사용자명 Return moWrkInfoBiz.UidMst.Item("UidNam").ToString
ETC_로그인_사용자ID Return moWrkInfoBiz.UidMst.Item("UidCod").ToString
ETC_로그인_사용자연락처 Return moWrkInfoBiz.UidMst.Item("UidMblPhn").ToString
ETC_로그인_원내연락처 Return moWrkInfoBiz.UidMst.Item("UIDOFFTEL").ToString
ETC_로그인_사용자ID_사용자명 Return moWrkInfoBiz.UidMst.Item("UidCod").ToString & "." & moWrkInfoBiz.UidMst.Item("UidNam").ToString
```
---
## 진행 상황과 다음 배치
옮긴 것 **23종**. 배치마다 게이트를 통과시키고 커밋한다 —
한 번에 몰아서 넣으면 어느 태그가 값을 틀리게 내는지 못 가린다.
### 완료 (23)
| 배치 | 종 | 무엇이 필요했나 |
| --- | --- | --- |
| 컬럼 직독 | 7 | 없음 |
| 주민번호 파생 | 4 | DB 복호화(`CRYPTO_AES256`) + 보정 |
| 형식 가공 | 8 | 없음 |
| 주소 | 4 | `M_ZipMst` 조회 1회 |
### 다음 배치 (권장 순서)
1. **`OCM_담당의사` 계열 3종** — `GetCodInfDT` + `GetUidMst` 두 조회.
입원/외래로 갈리고 입원은 `ComPrgStt='ILV'` 여부로 한 번 더 갈린다.
조회 둘을 저장소에 만들고 나면 그 뒤 여러 태그가 같은 것을 쓴다.
2. **문맥만 읽는 나머지 ~25종** — 위 목록에서 1번이 푸는 것을 뺀 나머지.
3. **기타 55종** — 다른 라이브러리 호출. 하나씩 본문을 봐야 한다.
4. **자기 SQL 232종** — 여기가 본체다. 건당
① 본문 SQL 을 읽고 ② 바인드로 옮기고 ③ 실DB 로 확인한다.
묶음은 도메인별(처방·검사·간호기록…)로 자르는 것이 낫다 —
같은 표를 쓰는 것끼리 모이면 조회를 공유할 수 있다.
### 옮기지 않을 48종
`MessageBox` 를 띄우는 것들이다. 미리보기는 사람이 응답할 수 없는 자리에서 도므로
<b>원리상 실행할 수 없다.</b> 값 대신 "대화형이라 미리보기에서 확인할 수 없습니다"라고
말하게 두는 것이 맞다 — 조용히 비면 서식이 잘못된 줄 안다.
### 배치마다 지킬 것
- 레거시 함수 본문을 읽고 근거를 주석에 남긴다(줄 번호까지).
- 태그 이름은 `--edit-smoke` 의 이름 검사가 확인한다 —
지어낸 이름 셋이 조용히 앉아 있던 적이 있다.
- 짐작한 컬럼·계산식은 넣지 않는다. 종이에 그럴듯한 값이 찍히면 아무도 의심하지 않는다.
- 값이 아니라 <b>사유</b>를 돌려주는 쪽이 항상 안전하다.
---
## 배치 1 준비 — `OCM_담당의사` 계열 (SQL 확보 완료)
`GetCodInfDT(comNum, adpDtm, 1)` = `EN_JOIN.With_Join` (dtCommonLib.vb:2777-2830):
```sql
SELECT Cod.*, DepKorNam CodDepCod_Nam,
Usr.UidNam CodDtrCod_Nam, Umt.UidNam CodStfCod_Nam
FROM P_CodInf Cod
LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'
AND :d BETWEEN Usr.UidStrDte AND Usr.UidEndDte
LEFT OUTER JOIN M_UidMst Umt ON Umt.UidCod = CodStfDtr AND Umt.UidDtrYon = 'Y'
AND :d BETWEEN Umt.UidStrDte AND Umt.UidEndDte
LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod
AND DepWrkTyp IN ('D','A') -- SYBS·NYHY 는 ('D','A','B')
AND :d BETWEEN DepStrDte AND DepEndDte
WHERE CodComNum = :n AND CodMtiSeq = '0'
```
`:d` 는 넘긴 적용일시의 **앞 8자리**(비면 서버 날짜).
호출 갈래(bzDataInterface.vb `OCM_담당의사`):
| 조건 | 넘기는 시각 | UidMst 조회 날짜 |
| --- | --- | --- |
| 입원 · `ComPrgStt='ILV'` | `ComLevDtm[0..12]` | `CodEndDtm[0..8]` |
| 입원 · 그 밖 | 서버 현재일시 | `CodEndDtm[0..8]` |
| 외래 | `ComAcpDtm[0..12]` | `ComAcpDtm[0..8]` |
세 태그의 차이는 마지막 한 줄뿐이다 —
`UidNam` / `UidNam(UidCod)` / `UidMblPhn`.
### 옮기기 전에 정해야 할 것 둘
**① 이 조회에는 정렬이 없다.** `CodComNum` + `CodMtiSeq='0'` 으로만 거르고
`ORDER BY` 가 없는데 호출부는 `Rows(0)` 을 집는다. 한 내원에 진료과 이력이 여럿이면
<b>오라클이 어느 행을 먼저 주느냐에 따라 담당의가 달라진다.</b>
레거시의 실제 동작이 비결정적이라는 뜻이라, "그대로 옮긴다"가 성립하지 않는다.
정렬을 넣으면 레거시와 달라질 수 있고, 안 넣으면 우리도 비결정적이 된다.
→ 어느 쪽인지 사용자와 정하고 근거를 주석에 남긴다.
**② 두 번 조회한다.** 위 SQL 이 이미 `CodDtrCod_Nam`(담당의 이름)을 돌려주는데
태그는 그것을 쓰지 않고 `GetUidMst` 로 <b>다른 날짜</b>를 써서 다시 찾는다.
날짜가 다르므로 두 값이 갈릴 수 있다. 어느 쪽이 맞는지는 레거시 동작을 따라야 한다
(= 두 번째 조회 결과를 쓴다). 왕복을 줄이려고 첫 결과를 쓰면 값이 달라진다.
또한 이 조회는 `PatientContextStore` 가 이미 읽는 `CodInf` 와 <b>선택 규칙이 다르다</b>
(그쪽은 적용일시 BETWEEN + 최신순 1행). 같은 표를 두 규칙으로 읽게 되므로
문맥의 `CodInf` 를 재사용하면 안 된다.
### 조사 결과 — ① 은 실재하는 문제다
운영 DB(SRCH_TEST)에서 센 결과:
| 항목 | 값 |
| --- | --- |
| `CodMtiSeq=0` 행이 2개 이상인 내원 | **25,488건** |
| 한 내원의 최대 행수 | **41행** |
0건이면 없는 문제였겠지만 아니다. 그리고 41행짜리 내원에서
정렬 없는 `Rows(0)` 은 <b>사실상 무작위</b>다.
즉 레거시는 지금 이 순간에도 그 25,488건에 대해 담당의를 확정적으로 찍고 있지 않다.
**"레거시 그대로"는 선택지가 아니다** — 재현할 대상 자체가 없다.
#### 정황 하나 더
레거시는 `GetCodInfDT` 에 적용일시를 <b>넘기면서도 그것으로 거르지 않는다</b>.
넘긴 날짜는 `M_UidMst`·`M_DepMst` 의 유효기간 조인에만 쓰인다.
`P_CodInf` 자체에는 `CodStrDtm`/`CodEndDtm` 이 있는데 `WHERE` 에 없다 —
"그 시점의 진료과"를 뽑으려던 의도가 코드에 반쯤만 구현된 것으로 보인다.
`PatientContextStore` 가 문맥의 `CodInf` 를 읽을 때는 그 조건을 이미 쓴다
(`:t BETWEEN CodStrDtm AND CodEndDtm`, 최신순 1행).
#### 권고
적용일시로 거르고 최신순 1행을 집는다 — 문맥 조회와 같은 규칙.
결정적이 되고, 레거시가 의도한 것으로 보이는 동작과도 맞는다.
이는 <b>의도적으로 레거시와 다르게 가는</b> 첫 사례이므로 코드 주석과 여기에 근거를 남긴다.
+391
View File
@@ -0,0 +1,391 @@
# 남은 태그 360종 색인 — 완전 호환까지의 실제 목록
`bzDataInterface.vb` 386종 중 **26종 완료**, 360종 남음.
## 앞선 분류를 정정한다
전에 "MessageBox 를 띄우는 48종은 대화형이라 미리보기에서 원리상 실행할 수 없다"고 적었다.
**틀렸다.** MessageBox 는 전부 `Catch ex As Exception` 안에 있다 — 오류 경로일 뿐이다.
우리는 예외 시 대화상자 대신 사유를 돌려주면 된다.
**옮길 수 없는 태그는 없다.** 남은 것은 물량이다.
| 갈래 | 수 | 성격 |
| --- | --- | --- |
| CTX | 126 | 문맥·계산만 — 조회 없이 옮길 수 있다 |
| SQL | 231 | 본문에 자기 SQL — 읽고 바인드로 옮기고 실DB 로 확인 |
| HSP | 3 | 병원 코드로 갈린다 |
**CTX 126종이 다음 목표다.** 조회를 새로 만들 필요가 없어 배치로 빠르게 나간다.
## 규칙 (배치마다)
- 레거시 함수 본문을 읽고 근거를 주석에 줄 번호까지 남긴다.
- 태그 이름은 `--edit-smoke` 의 이름 검사가 확인한다 — 지어낸 이름 셋이 조용히 앉아 있던 적이 있다.
- 짐작한 컬럼·계산식은 넣지 않는다. 종이에 그럴듯한 값이 찍히면 아무도 의심하지 않는다.
- 레거시가 비결정적이면(담당의 건처럼) 그 사실을 적고 결정적 규칙을 고른다.
## 목록 (줄 번호는 bzDataInterface.vb 기준)
| 갈래 | 태그 | 줄 |
| --- | --- | --- |
| SQL | `PAT_주민번호_Blind` | 105 |
| SQL | `PAT_주민번호_SexTyp_Blind` | 133 |
| SQL | `PAT_주민번호_SexTyp_BlindStar` | 160 |
| SQL | `PAT_실제생년월일` | 226 |
| SQL | `PAT_성별_남` | 317 |
| SQL | `PAT_성별_여` | 338 |
| HSP | `PAT_성별_나이` | 359 |
| SQL | `PAT_나이` | 373 |
| CTX | `PAT_나이_세` | 417 |
| CTX | `PAT_나이_개월수` | 430 |
| CTX | `PAT_나이_00개월_00일` | 436 |
| CTX | `PAT_나이_00Months_00Days` | 453 |
| CTX | `PAT_나이_개월_00일` | 471 |
| CTX | `PAT_나이_00개월_일` | 489 |
| SQL | `PAT_국적` | 639 |
| SQL | `PAT_장애등급` | 687 |
| SQL | `PAT_건보세대주명` | 732 |
| SQL | `PAT_건보_급여_세대주명` | 780 |
| SQL | `PAT_협력업체` | 837 |
| SQL | `PAT_보호자연락처` | 881 |
| CTX | `OCM_입통원구분_입원` | 989 |
| CTX | `OCM_입통원구분_입원_Refer` | 999 |
| CTX | `OCM_입통원구분_통원` | 1009 |
| CTX | `OCM_입통원구분_통원_Refer` | 1019 |
| SQL | `OCM_FollowUp` | 1028 |
| CTX | `OCM_외래내원일시` | 1068 |
| CTX | `OCM_외래내원일시_Refer` | 1074 |
| CTX | `OCM_외래내원일자_영문` | 1091 |
| CTX | `OCM_외래내원일자_Refer` | 1097 |
| SQL | `OCM_전과내역` | 1103 |
| SQL | `OCM_전과내역_Refer` | 1160 |
| SQL | `OCM_외래실진료일` | 1217 |
| SQL | `OCM_외래실진료일_refer` | 1361 |
| SQL | `OCM_외래실진료일_과목별` | 1503 |
| SQL | `OCM_외래실진료일수` | 1645 |
| SQL | `OCM_외래실진료일수_Refer` | 1764 |
| SQL | `OCM_외출외박신청일` | 1883 |
| SQL | `OCM_외출외박종료일` | 1936 |
| SQL | `OCM_최초내원일` | 1988 |
| SQL | `OCM_초진일_발병일` | 2040 |
| SQL | `OCM_초진일_발병일_Refer` | 2089 |
| SQL | `OCM_신환_초진일` | 2138 |
| SQL | `OCM_신환_초진일_Refer` | 2187 |
| SQL | `OCM_진료기간` | 2236 |
| SQL | `OCM_진료기간_Refer` | 2411 |
| SQL | `OCM_진료기간_진료과` | 2575 |
| SQL | `OCM_진료기간_진료과_Refer` | 2676 |
| SQL | `OCM_진료과_전화번호` | 2773 |
| SQL | `OCM_입원내원일시` | 2824 |
| CTX | `OCM_입원내원일시_Refer` | 2834 |
| SQL | `OCM_입원내원일자` | 2840 |
| SQL | `OCM_입원내원일자_Refer` | 2851 |
| CTX | `OCM_입원전환일자_Refer` | 2879 |
| CTX | `OCM_입원전환일시_Refer` | 2890 |
| CTX | `OCM_내원유형` | 2896 |
| SQL | `OCM_수술일자` | 2920 |
| SQL | `OCM_수술일자_마지막수술` | 2999 |
| SQL | `OCM_수술소독간호사` | 3028 |
| SQL | `OCM_수술진단명` | 3066 |
| SQL | `OCM_수술마취의사` | 3109 |
| SQL | `OCM_수술집도의_진료과` | 3157 |
| SQL | `OCM_수술집도의_그룹진료과` | 3194 |
| SQL | `OCM_수술마취의사싸인_수술접수연동` | 3235 |
| SQL | `OCM_수술마취의사싸인` | 3311 |
| SQL | `OCM_수술집도의_싸인` | 3385 |
| CTX | `OCM_수술일자_Refer` | 3468 |
| SQL | `OCM_수술처치` | 3474 |
| SQL | `OCM_수술명칭` | 3520 |
| SQL | `OCM_당뇨식칼로리_List` | 3569 |
| SQL | `ETC_수술실간호사` | 3606 |
| SQL | `ETC_마취과의사List_InitialDatatable` | 3635 |
| SQL | `ETC_제한향균제_InitialDatatable` | 3669 |
| SQL | `ETC_마취간호사List_InitialDatatable` | 3717 |
| SQL | `ETC_어시스트간호사List_InitialDatatable` | 3746 |
| SQL | `ETC_소독간호사List_InitialDatatable` | 3776 |
| SQL | `ETC_순환간호사List_InitialDatatable` | 3806 |
| SQL | `ETC_마취및소독간호사List_InitialDatatable` | 3835 |
| SQL | `ETC_승인의사` | 3892 |
| CTX | `OCM_적용진료의사` | 3939 |
| CTX | `OCM_STFDTR` | 4026 |
| CTX | `OCM_담당의사_Refer` | 4069 |
| CTX | `OCM_담당의사_영문` | 4094 |
| CTX | `OCM_담당의사_영문_Refer` | 4119 |
| CTX | `OCM_담당의사면허번호` | 4144 |
| CTX | `OCM_담당의사면허번호_Refer` | 4169 |
| CTX | `OCM_담당의사전문의번호` | 4194 |
| CTX | `OCM_담당의사전문의번호_Refer` | 4219 |
| CTX | `OCM_담당의사싸인` | 4244 |
| CTX | `OCM_담당의사싸인_Refer` | 4311 |
| CTX | `OCM_진단서의사명` | 4380 |
| CTX | `OCM_진단서의사명_외래` | 4410 |
| CTX | `OCM_진단서의사명_Refer` | 4435 |
| CTX | `OCM_진단서의사_영문` | 4465 |
| CTX | `OCM_진단서의사_영문_Refer` | 4493 |
| CTX | `OCM_진단서의사면허번호` | 4521 |
| CTX | `OCM_진단서의사면허번호_Refer` | 4550 |
| CTX | `OCM_진단서의사전문의번호` | 4579 |
| CTX | `OCM_진단서의사세부전문의번호` | 4607 |
| CTX | `OCM_진단서의사전문의번호_Refer` | 4635 |
| SQL | `OCM_진단서전문과목` | 4663 |
| SQL | `OCM_진단서전문과목_Refer` | 4726 |
| SQL | `OCM_진단서청구진료과목_로그인` | 4790 |
| CTX | `OCM_로그인_사용자싸인` | 4847 |
| CTX | `OCM_진단서의사싸인` | 4904 |
| CTX | `OCM_진단서의사싸인_Refer` | 5019 |
| SQL | `OCM_계산유형` | 5133 |
| SQL | `OCM_보험유형` | 5186 |
| SQL | `OCM_보험유형_Refer` | 5236 |
| SQL | `OCM_수납일시` | 5287 |
| SQL | `OCM_수납일시_Refer` | 5362 |
| SQL | `OCM_진료과` | 5437 |
| SQL | `OCM_진료과_Refer` | 5497 |
| SQL | `OCM_진료과_그룹코드` | 5556 |
| SQL | `OCM_진료과_영어` | 5606 |
| SQL | `OCM_적용진료과` | 5659 |
| CTX | `OCM_User진료과` | 5697 |
| CTX | `OCM_User진료과_한글명칭` | 5734 |
| CTX | `OCM_User진료과_Refer` | 5762 |
| CTX | `OCM_User진료의사명` | 5799 |
| CTX | `OCM_User진료의사명_Refer` | 5829 |
| SQL | `OCM_진료과대외명칭` | 5859 |
| SQL | `OCM_진료과대외명칭_Refer` | 5909 |
| SQL | `OCM_진료과약어명칭` | 5959 |
| SQL | `OCM_입원과` | 6009 |
| SQL | `OCM_입원과_한글명칭` | 6061 |
| SQL | `OCM_입원과_Refer` | 6110 |
| SQL | `OCM_퇴원과` | 6162 |
| SQL | `OCM_퇴원과_한글명칭` | 6231 |
| SQL | `OCM_퇴원과_Refer` | 6296 |
| SQL | `OCM_협진과` | 6360 |
| SQL | `OCM_협진과_협진의` | 6437 |
| SQL | `OCM_입원의사` | 6499 |
| SQL | `OCM_입원의사_Refer` | 6541 |
| SQL | `OCM_퇴원의사` | 6583 |
| SQL | `OCM_퇴원의사_Refer` | 6652 |
| CTX | `OCM_병동` | 6718 |
| CTX | `OCM_병동_Refer` | 6760 |
| SQL | `OCM_병실` | 6801 |
| SQL | `OCM_병실_Refer` | 6874 |
| CTX | `OCM_병실베드` | 6946 |
| CTX | `OCM_병동_병실` | 7000 |
| CTX | `OCM_병동_병실_베드` | 7033 |
| SQL | `OCM_병실인실` | 7065 |
| SQL | `OCM_병실차액` | 7103 |
| SQL | `OCM_입원시간` | 7152 |
| CTX | `OCM_입원시간_Refer` | 7174 |
| SQL | `OCM_입원일시` | 7191 |
| SQL | `OCM_입원일시_영문` | 7216 |
| CTX | `OCM_입원일시_Refer` | 7242 |
| SQL | `OCM_입원일자_낮병동` | 7260 |
| SQL | `OCM_퇴원예고일시` | 7301 |
| CTX | `OCM_의사퇴원예고일시` | 7388 |
| CTX | `OCM_퇴원예정일시` | 7421 |
| HSP | `OCM_퇴원시간` | 7454 |
| HSP | `OCM_퇴원시간_Refer` | 7480 |
| SQL | `OCM_퇴원일시` | 7508 |
| SQL | `OCM_퇴원일시_영문` | 7604 |
| SQL | `OCM_퇴원일시_Refer` | 7698 |
| SQL | `OCM_퇴원일자_낮병동` | 7763 |
| CTX | `OCM_입실시간` | 7803 |
| SQL | `OCM_퇴원예고재원일수` | 7817 |
| SQL | `OCM_재원일수` | 7877 |
| SQL | `OCM_재원일수_단입법` | 7947 |
| SQL | `OCM_재원일수_Refer` | 8017 |
| SQL | `OCM_재원일수_낮병동` | 8083 |
| SQL | `OCM_발병일` | 8125 |
| CTX | `OCM_상병명_가로` | 8170 |
| CTX | `OCM_상병명_가로_Refer` | 8233 |
| SQL | `OCM_상병명_세로` | 8293 |
| SQL | `OCM_상병명_세로_Refer` | 8373 |
| CTX | `OCM_상병명_영어_가로` | 8469 |
| CTX | `OCM_상병명_영어_가로_Refer` | 8517 |
| CTX | `OCM_상병명_영어_세로` | 8565 |
| CTX | `OCM_상병명_영어_세로_Refer` | 8606 |
| CTX | `OCM_상병코드_가로` | 8647 |
| CTX | `OCM_상병코드_가로_Refer` | 8695 |
| CTX | `OCM_상병코드_세로` | 8743 |
| CTX | `OCM_상병코드_세로_Refer` | 8784 |
| SQL | `OCM_상병코드_상병명` | 8825 |
| SQL | `OCM_상병코드_상병명_Refer` | 8913 |
| CTX | `OCM_진단구분_상병코드_상병명` | 9017 |
| CTX | `OCM_진단구분_상병명` | 9070 |
| CTX | `OCM_진단구분_상병코드_상병명_Refer` | 9133 |
| SQL | `OCM_상병명_상병코드` | 9185 |
| SQL | `OCM_상병명_상병코드_Refer` | 9273 |
| CTX | `OCM_주상병일자` | 9360 |
| CTX | `OCM_주상병일자_Refer` | 9401 |
| CTX | `OCM_주상병POA` | 9442 |
| CTX | `OCM_주상병POA_Refer` | 9476 |
| SQL | `OCM_상병코드_상병명_주상병` | 9508 |
| SQL | `OCM_상병코드_상병명_주상병_Refer` | 9562 |
| SQL | `OCM_상병코드_상병명_부상병` | 9616 |
| SQL | `OCM_상병코드_상병명_부상병_Refer` | 9673 |
| SQL | `OCM_주상병코드` | 9730 |
| CTX | `OCM_주상병코드_Refer` | 9769 |
| CTX | `OCM_부상병코드` | 9801 |
| CTX | `OCM_부상병코드_Refer` | 9842 |
| SQL | `OCM_부상병명` | 9884 |
| CTX | `OCM_부상병명_Refer` | 9949 |
| CTX | `OCM_부상병POA` | 9990 |
| CTX | `OCM_부상병POA_Refer` | 10031 |
| SQL | `OCM_주상병명` | 10072 |
| CTX | `OCM_주상병명_영어` | 10124 |
| CTX | `OCM_주상병명_Refer` | 10156 |
| CTX | `OCM_주상병명_영어_Refer` | 10188 |
| CTX | `OCM_심사_주상병코드` | 10220 |
| CTX | `OCM_심사_주상병명` | 10253 |
| CTX | `OCM_심사_부상병코드` | 10285 |
| CTX | `OCM_심사_부상병명` | 10326 |
| CTX | `OCM_주상병특정기호` | 10367 |
| CTX | `OCM_주상병특정기호_Refer` | 10398 |
| SQL | `OCM_주상병명_주상병코드` | 10429 |
| SQL | `퇴원약DT` | 10482 |
| CTX | `OCM_퇴원약_코드` | 10516 |
| CTX | `OCM_퇴원약_명칭` | 10534 |
| CTX | `OCM_퇴원약_용량` | 10552 |
| CTX | `OCM_퇴원약_횟수` | 10568 |
| CTX | `OCM_퇴원약_일수` | 10584 |
| CTX | `OCM_퇴원약_용법` | 10600 |
| SQL | `OCM_퇴원약` | 10617 |
| SQL | `OCM_퇴원약_New` | 10733 |
| SQL | `OCM_퇴원약_Refer` | 10893 |
| SQL | `OCM_비급여처방` | 10964 |
| SQL | `OCM_진단서전문그룹과목` | 11245 |
| SQL | `OCM_진단서전문그룹과목_Refer` | 11322 |
| SQL | `OCM_청구진료과목` | 11398 |
| SQL | `OCM_청구진료과목_Refer` | 11453 |
| SQL | `OCM_키` | 11508 |
| SQL | `OCM_몸무게` | 11549 |
| SQL | `OCM_체온` | 11589 |
| SQL | `OCM_혈압` | 11627 |
| SQL | `OCM_혈압_BPS` | 11666 |
| SQL | `OCM_혈압_BPD` | 11705 |
| SQL | `OCM_머리둘레` | 11744 |
| SQL | `OCM_혈액형` | 11783 |
| SQL | `OCM_LMP` | 11813 |
| SQL | `OCM_임신주기` | 11845 |
| SQL | `OCM_맥박` | 11928 |
| SQL | `OCM_호흡` | 11966 |
| SQL | `OCM_SPO2` | 12004 |
| SQL | `OCM_BST` | 12042 |
| SQL | `OCM_키_LAST` | 12081 |
| SQL | `OCM_몸무게_LAST` | 12120 |
| SQL | `OCM_BMI` | 12159 |
| SQL | `OCM_표준체중_LAST` | 12234 |
| SQL | `OCM_조정체중_LAST` | 12284 |
| SQL | `OCM_비만도` | 12333 |
| SQL | `OCM_체온_LAST` | 12424 |
| SQL | `OCM_혈압_LAST` | 12463 |
| SQL | `OCM_맥박_LAST` | 12503 |
| SQL | `OCM_호흡_LAST` | 12542 |
| SQL | `OCM_머리둘레_LAST` | 12581 |
| SQL | `OCM_당일식이` | 12620 |
| SQL | `OCM_SPO2_LAST` | 12671 |
| SQL | `OCM_BST_LAST` | 12710 |
| SQL | `OCM_응급증상여부_YES` | 12748 |
| SQL | `OCM_응급증상여부_NO` | 12830 |
| CTX | `OCM_전과` | 12924 |
| CTX | `OCM_전과일시` | 12985 |
| CTX | `OCM_전과일자` | 12990 |
| SQL | `OCM_VITAL접수일시_LAST` | 12996 |
| CTX | `OCM_간호정보조사지_과거력` | 13030 |
| CTX | `OCM_OPNAME` | 13099 |
| CTX | `OCM_OPRCODNAM` | 13111 |
| CTX | `OCM_DX` | 13123 |
| CTX | `OCM_수술상병` | 13135 |
| CTX | `OCM_수술주상병` | 13148 |
| CTX | `OCM_수술집도의` | 13164 |
| CTX | `OCM_수술부위` | 13177 |
| CTX | `OCM_수술_OprPatETC` | 13190 |
| SQL | `GetOprInfDT` | 13203 |
| SQL | `GetOprInfDT_OKDMain` | 13364 |
| SQL | `GetNurseEmrDT` | 13523 |
| SQL | `GetNurseShtCod_GCRCH` | 13620 |
| SQL | `GetNurseShtCod_GNBEDRO` | 13696 |
| SQL | `GetNurseShtCod` | 13770 |
| SQL | `GetNurseShtCod_SRH` | 13845 |
| SQL | `OCM_퇴원분석_수술처치명` | 13920 |
| SQL | `OCM_영상촬영이력` | 13968 |
| SQL | `OCM_영상촬영이력_Refer` | 14026 |
| SQL | `OCM_감염분류_감염정보` | 14084 |
| SQL | `OCM_감염정보` | 14122 |
| SQL | `OCM_Cosign_의사명` | 14812 |
| SQL | `OCM_Cosign_싸인` | 14853 |
| SQL | `OCM_알러지` | 14941 |
| SQL | `OCM_알러지_조영제` | 14981 |
| SQL | `OCM_알러지_약물` | 15022 |
| SQL | `OCM_알러지_음식` | 15062 |
| SQL | `OCM_알러지_기타` | 15102 |
| SQL | `OCM_알러지_List` | 15142 |
| SQL | `OCM_외출예상시작일시` | 15197 |
| SQL | `OCM_외출예상종료일시` | 15231 |
| SQL | `ETC_건강보험증번호` | 15269 |
| SQL | `ETC_재해발생일` | 15323 |
| CTX | `ETC_건강보험증번호_Refer` | 15352 |
| SQL | `ETC_수술간호사목록_InitalDatatable` | 15387 |
| SQL | `ETC_수술간호사목록` | 15414 |
| SQL | `ETC_수술_내시경간호사목록_InitalDatatable` | 15439 |
| SQL | `ETC_로그인사용자_병동간호사목록_InitalDatatable` | 15466 |
| SQL | `ETC_로그인사용자_병동_수간호사` | 15507 |
| SQL | `ETC_로그인사용자_병동_수간호사_싸인` | 15562 |
| SQL | `ETC_요양기관기호` | 15656 |
| SQL | `ETC_산재지정번호` | 15727 |
| SQL | `ETC_요양기관명칭_병원명` | 15792 |
| SQL | `ETC_요양기관명칭_병원명_영문` | 15862 |
| SQL | `ETC_요양기관명칭_병원주소` | 15931 |
| SQL | `ETC_요양기관명칭_병원주소_영문` | 15996 |
| SQL | `ETC_요양기관명칭_병원전화번호` | 16061 |
| SQL | `ETC_요양기관명칭_영문_병원전화_팩스번호` | 16126 |
| SQL | `ETC_요양기관명칭_병원전화_팩스번호` | 16196 |
| SQL | `ETC_요양기관명칭_병원팩스번호` | 16266 |
| SQL | `ETC_요양기관명칭_병원대표자` | 16335 |
| SQL | `ETC_요양기관명칭_병원장귀하` | 16400 |
| SQL | `ETC_요양기관명칭_병원장` | 16469 |
| SQL | `ETC_요양기관명칭_병원로고` | 16538 |
| SQL | `ETC_요양기관명칭_병원직인` | 16580 |
| SQL | `ETC_요양기관명칭_사업자등록번호` | 16646 |
| CTX | `ETC_로그인_사용자명` | 16711 |
| CTX | `ETC_로그인_사용자ID` | 16718 |
| CTX | `ETC_로그인_사용자연락처` | 16724 |
| CTX | `ETC_로그인_원내연락처` | 16730 |
| CTX | `ETC_로그인사용자_서명` | 16736 |
| SQL | `ETC_전체진료과_한글명` | 16797 |
| SQL | `ETC_전체진료과_영문명` | 16817 |
| SQL | `ETC_의사List_이름순` | 16837 |
| SQL | `ETC_진료과별의사List_이름순` | 16871 |
| CTX | `ETC_현재일자` | 16892 |
| CTX | `ETC_현재일자_영문` | 16897 |
| CTX | `ETC_현재일시` | 16903 |
| CTX | `ETC_현재일시_MaskBox` | 16909 |
| CTX | `ETC_현재시간` | 16915 |
| CTX | `ETC_현재년도` | 16921 |
| CTX | `ETC_현재일시_1_년` | 16927 |
| CTX | `ETC_현재일시_2_월` | 16933 |
| CTX | `ETC_현재일시_3_일` | 16939 |
| CTX | `ETC_현재일시_4_몇시` | 16944 |
| CTX | `ETC_현재일시_5_몇분` | 16949 |
| SQL | `ETC_수술일자_1_몇년2자리` | 16955 |
| SQL | `ETC_수술일자_1_몇년` | 16984 |
| SQL | `ETC_수술일자_2_몇월` | 17013 |
| SQL | `ETC_수술일자_3_몇일` | 17042 |
| SQL | `ETC_로그인_근무부서_사용자명` | 17074 |
| SQL | `ETC_로그인_근무부서` | 17122 |
| SQL | `ETC_로그인_전문의번호` | 17175 |
| SQL | `ETC_로그인_의사면허번호` | 17202 |
| SQL | `ETC_로그인_전문의번호_Refer` | 17230 |
| SQL | `ETC_로그인_직급` | 17257 |
| SQL | `ETC_접속유저_부서번호` | 17287 |
| SQL | `ETC_병리판독의사명` | 17318 |
| SQL | `ETC_영상의학과의사싸인` | 17353 |
| SQL | `ETC_병리판독의사싸인` | 17434 |
| SQL | `ETC_병리판독의사전문의번호` | 17515 |
| SQL | `ETC_진단검사의사명` | 17550 |
| SQL | `ETC_진단검사의사싸인` | 17578 |
| SQL | `ETC_진단검사의사전문의번호` | 17655 |
| SQL | `ETC_외출외박_확인간호사싸인` | 17683 |
| SQL | `ETC_외출외박_확인원무과싸인` | 17765 |
| SQL | `ECT_로그인사용자싸인` | 17850 |
| CTX | `ETC_로그인_사용자ID_사용자명` | 17928 |
| SQL | `ETC_로그인_직종` | 17935 |
+167
View File
@@ -0,0 +1,167 @@
# 워크플로 최종 결과 — CTX 태그 126종 검증 완료
오케스트레이션 2회(에이전트 12개 완료, 오류 0). 레거시 본문을 병렬로 읽고
각 매핑을 <b>다른 에이전트가 반박</b>하게 했다.
| 판정 | 수 |
| --- | --- |
| ok | 118 |
| wrong | 6 |
| unsure | 2 |
고유 태그 105종.
## 통합 전에 반드시 확인할 것
이번 배치는 대부분 `ETC_` 계열(시각·로그인 사용자)로 <b>환자 태그가 아니다</b>.
상당수는 `SessionTagResolver`/`TagPreviewCatalog` 가 이미 처리한다 —
그대로 넣으면 같은 태그를 두 곳에서 처리하게 된다. 대조가 먼저다.
## 검증이 <b>기존 SheetMe 코드</b>의 차이를 셋 잡았다
매핑이 아니라 이미 들어가 있는 코드가 레거시와 다른 경우다.
1. `ETC_로그인_사용자ID_사용자명` — 레거시(17930)는 <b>항상</b> `UidCod + "." + UidNam` 을 만든다.
SheetMe `TagPreviewCatalog.cs:197` 은 둘 다 비어 있지 않을 때만 만들고 아니면 빈 값이다.
2. `ETC_로그인_사용자명` — SheetMe 조회(`UserContextStore.cs:62`)가 `NVL(UidNam,' ')` 로 공백 한 칸을 채운다.
`UidNam` 이 NULL 인 사용자에서 레거시("")와 값이 갈린다.
3. 로그인 사용자 유효기간 기준일 — 레거시는 `GetAdpDtm` 앞 8자(`clsCommonLib.vb:10622-10624`),
SheetMe 는 `SYSDATE`. 같은 행이라도 기준일이 다르다.
## 되살릴 때 걸리는 것
- `ETC_건강보험증번호_Refer` 는 죽은 코드(`Return ""` 하나)지만, 주석 속 원래 구현은
`ComInfDR_Refer`(<b>참조 내원행</b>)를 읽는다 — 다섯 문맥 표의 `ComInf` 로 그냥 치환하면 안 된다.
- `ETC_현재일시_MaskBox` 는 마스크 `9999-99-9999:99` 라 일과 시가 붙는다(`2026-08-1914:30`).
- `ETC_로그인_원내연락처` 컬럼은 `UIDOFFTEL` 대문자.
- `ETC_로그인사용자_서명` 은 서버에서 이미지를 <b>내려받는다</b>(경로 조립·재시도 루프·예외 분기).
`needs-lookup` 으로 격리했다.
## 판정 표
| 판정 | 태그 | kind | row | column |
| --- | --- | --- | --- | --- |
| ok | `ETC_건강보험증번호_Refer` | - | - | - |
| ok | `ETC_건강보험증번호_Refer` | computed | - | - |
| ok | `ETC_로그인_사용자ID_사용자명` | - | - | - |
| ok | `ETC_로그인_사용자ID_사용자명` | computed | - | UidCod,UidNam |
| ok | `ETC_로그인_사용자ID` | - | - | - |
| ok | `ETC_로그인_사용자ID` | context-scalar | - | UidCod |
| ok | `ETC_로그인_사용자명` | - | - | - |
| ok | `ETC_로그인_사용자명` | context-scalar | - | UidNam |
| ok | `ETC_로그인_사용자연락처` | - | - | - |
| ok | `ETC_로그인_사용자연락처` | context-scalar | - | UidMblPhn |
| ok | `ETC_로그인_원내연락처` | - | - | - |
| ok | `ETC_로그인_원내연락처` | context-scalar | - | UIDOFFTEL |
| ok | `ETC_로그인사용자_서명` | - | - | - |
| ok | `ETC_로그인사용자_서명` | needs-lookup | - | UidImgPth |
| ok | `ETC_현재년도` | - | - | - |
| ok | `ETC_현재년도` | formatted | - | - |
| ok | `ETC_현재시간` | - | - | - |
| ok | `ETC_현재시간` | formatted | - | - |
| ok | `ETC_현재일시_1_년` | - | - | - |
| ok | `ETC_현재일시_1_년` | formatted | - | - |
| ok | `ETC_현재일시_2_월` | - | - | - |
| ok | `ETC_현재일시_2_월` | formatted | - | - |
| ok | `ETC_현재일시_3_일` | - | - | - |
| ok | `ETC_현재일시_3_일` | formatted | - | - |
| ok | `ETC_현재일시_4_몇시` | - | - | - |
| ok | `ETC_현재일시_4_몇시` | formatted | - | - |
| ok | `ETC_현재일시_5_몇분` | - | - | - |
| ok | `ETC_현재일시_5_몇분` | formatted | - | - |
| ok | `ETC_현재일시_MaskBox` | - | - | - |
| ok | `ETC_현재일시_MaskBox` | formatted | - | - |
| ok | `ETC_현재일시` | - | - | - |
| ok | `ETC_현재일시` | formatted | - | - |
| ok | `ETC_현재일자_영문` | - | - | - |
| ok | `ETC_현재일자_영문` | formatted | - | - |
| ok | `ETC_현재일자` | - | - | - |
| ok | `ETC_현재일자` | formatted | - | - |
| ok | `OCM_OPNAME` | - | - | - |
| ok | `OCM_OPNAME` | needs-lookup | - | OPNAME |
| ok | `OCM_STFDTR` | needs-lookup | - | - |
| ok | `OCM_User진료과_Refer` | needs-lookup | - | - |
| ok | `OCM_User진료과_한글명칭` | needs-lookup | - | - |
| ok | `OCM_User진료과` | needs-lookup | - | - |
| ok | `OCM_User진료의사명_Refer` | needs-lookup | - | - |
| ok | `OCM_User진료의사명` | needs-lookup | - | - |
| ok | `OCM_내원유형` | computed | ComInf | ComPatTyp, ComEmgYon |
| ok | `OCM_담당의사_Refer` | needs-lookup | - | - |
| ok | `OCM_담당의사_영문_Refer` | needs-lookup | - | - |
| ok | `OCM_담당의사_영문` | needs-lookup | - | - |
| ok | `OCM_담당의사면허번호_Refer` | needs-lookup | - | - |
| ok | `OCM_담당의사면허번호` | needs-lookup | - | - |
| ok | `OCM_담당의사싸인_Refer` | needs-lookup | - | - |
| ok | `OCM_담당의사싸인` | needs-lookup | - | - |
| ok | `OCM_담당의사전문의번호_Refer` | needs-lookup | - | - |
| ok | `OCM_담당의사전문의번호` | needs-lookup | - | - |
| ok | `OCM_로그인_사용자싸인` | needs-lookup | - | - |
| ok | `OCM_병동_Refer` | needs-lookup | CowInf | CowWadCod |
| ok | `OCM_병동_병실_베드` | needs-lookup | CowInf | CowWadCod + CowRomCod + CowBedCod |
| ok | `OCM_병동_병실` | needs-lookup | CowInf | CowWadCod, CowRomCod |
| ok | `OCM_병동` | needs-lookup | CowInf | CowWadCod |
| ok | `OCM_병실베드` | needs-lookup | CowInf | CowBedCod |
| ok | `OCM_부상병POA_Refer` | needs-lookup | - | OkdPoaTyp |
| ok | `OCM_부상병POA` | needs-lookup | - | OkdPoaTyp |
| ok | `OCM_부상병명_Refer` | needs-lookup | - | KcdKorNam |
| ok | `OCM_부상병코드_Refer` | needs-lookup | - | KcdElcCod |
| ok | `OCM_부상병코드` | needs-lookup | - | KcdElcCod |
| ok | `OCM_상병명_가로_Refer` | needs-lookup | - | KcdKorNam + OkdCfmSus |
| ok | `OCM_상병명_가로` | needs-lookup | - | KcdKorNam + OkdCfmSus |
| ok | `OCM_상병명_영어_가로_Refer` | needs-lookup | - | KcdEngNam |
| ok | `OCM_상병명_영어_가로` | needs-lookup | - | KcdEngNam |
| ok | `OCM_상병명_영어_세로_Refer` | needs-lookup | - | KcdEngNam |
| ok | `OCM_상병명_영어_세로` | needs-lookup | - | KcdEngNam |
| ok | `OCM_상병코드_가로_Refer` | needs-lookup | - | KcdElcCod |
| ok | `OCM_상병코드_가로` | needs-lookup | - | KcdElcCod |
| ok | `OCM_상병코드_세로_Refer` | needs-lookup | - | KcdElcCod |
| ok | `OCM_상병코드_세로` | needs-lookup | - | KcdElcCod |
| ok | `OCM_수술_OprPatETC` | needs-lookup | - | OprPatEtc |
| ok | `OCM_수술부위` | needs-lookup | - | OprRegionName |
| ok | `OCM_수술상병` | needs-lookup | - | OkdNam |
| ok | `OCM_수술일자_Refer` | computed | - | - |
| ok | `OCM_수술주상병` | needs-lookup | - | - |
| ok | `OCM_심사_부상병명` | needs-lookup | - | - |
| ok | `OCM_심사_부상병코드` | needs-lookup | - | - |
| ok | `OCM_심사_주상병명` | needs-lookup | - | - |
| ok | `OCM_심사_주상병코드` | needs-lookup | - | - |
| ok | `OCM_외래내원일시_Refer` | formatted | ComInf | ComAcpDtm |
| ok | `OCM_외래내원일시` | formatted | ComInf | ComAcpDtm |
| ok | `OCM_외래내원일자_Refer` | formatted | ComInf | ComAcpDtm |
| ok | `OCM_외래내원일자_영문` | formatted | ComInf | ComAcpDtm |
| ok | `OCM_의사퇴원예고일시` | computed | ComInf | ComSchLev |
| ok | `OCM_입실시간` | formatted | ComInf | ComTrsDtm |
| ok | `OCM_입원내원일시_Refer` | formatted | ComInf | ComAcpDtm |
| ok | `OCM_입원시간_Refer` | computed | ComInf | ComAcpDtm |
| ok | `OCM_입원일시_Refer` | computed | ComInf | ComAcpDtm |
| ok | `OCM_입원전환일시_Refer` | formatted | ComInf | ComTrsDtm |
| ok | `OCM_입원전환일자_Refer` | formatted | ComInf | ComTrsDtm |
| ok | `OCM_입통원구분_입원_Refer` | computed | ComInf | ComPatTyp |
| ok | `OCM_입통원구분_입원` | computed | ComInf | ComPatTyp |
| ok | `OCM_입통원구분_통원_Refer` | computed | ComInf | ComPatTyp |
| ok | `OCM_입통원구분_통원` | computed | ComInf | ComPatTyp |
| ok | `OCM_적용진료의사` | needs-lookup | CodInf | CodDtrCod |
| ok | `OCM_전과` | needs-lookup | CodInf | CodDepCod, CodDepCod_Nam, CodStrDtm, CodEndDtm |
| ok | `OCM_주상병POA_Refer` | needs-lookup | - | OkdPoaTyp |
| ok | `OCM_주상병POA` | needs-lookup | - | OkdPoaTyp |
| ok | `OCM_주상병명_Refer` | needs-lookup | - | KcdKorNam |
| ok | `OCM_주상병명_영어_Refer` | needs-lookup | - | KcdEngNam |
| ok | `OCM_주상병명_영어` | needs-lookup | - | KcdEngNam |
| ok | `OCM_주상병일자_Refer` | needs-lookup | - | OkdEntDtf |
| ok | `OCM_주상병일자` | needs-lookup | - | OkdEntDtf |
| ok | `OCM_주상병코드_Refer` | needs-lookup | - | KcdElcCod |
| ok | `OCM_주상병특정기호_Refer` | needs-lookup | - | OkdVeeCod |
| ok | `OCM_주상병특정기호` | needs-lookup | - | OkdVeeCod |
| ok | `OCM_진단구분_상병명` | needs-lookup | - | OkdDspSeq, OkdCfmSus, KcdKorNam |
| ok | `OCM_진단구분_상병코드_상병명_Refer` | needs-lookup | - | OkdDspSeq, OkdCfmSus, KcdElcCod, KcdKorNam |
| ok | `OCM_진단구분_상병코드_상병명` | needs-lookup | - | OkdDspSeq, OkdCfmSus, KcdElcCod, KcdKorNam |
| ok | `OCM_진단서의사_영문_Refer` | needs-lookup | ComInf | ComPatTyp, ComPrgStt, ComLevDtm, ComAcpDtm |
| ok | `OCM_진단서의사_영문` | needs-lookup | ComInf | ComPatTyp, ComPrgStt, ComLevDtm, ComAcpDtm |
| ok | `OCM_진단서의사면허번호_Refer` | needs-lookup | ComInf | ComPatTyp, ComPrgStt, ComLevDtm, ComAcpDtm |
| ok | `OCM_진단서의사면허번호` | needs-lookup | ComInf | ComPatTyp, ComPrgStt, ComLevDtm, ComAcpDtm |
| unsure | `OCM_전과일시` | needs-lookup(정책상) / formatted(실질) | CodInf | CodStrDtm |
| unsure | `OCM_전과일자` | needs-lookup(정책상) / formatted(실질) | CodInf | CodStrDtm |
| wrong | `OCM_DX` | needs-lookup | - | DX |
| wrong | `OCM_OPRCODNAM` | needs-lookup | - | OPRCODNAM |
| wrong | `OCM_간호정보조사지_과거력` | needs-lookup | - | - |
| wrong | `OCM_수술집도의` | needs-lookup | - | - |
+289
View File
@@ -0,0 +1,289 @@
# SQL 태그 231종 조사 결과 — 이식 전에 알아야 할 것
오케스트레이션(에이전트 14, 183만 토큰, 도구 413회). 추출 7 + 반박 검증 7.
| 판정 | 수 |
| --- | --- |
| ok | 210 |
| wrong | 21 |
고유 태그 197종(같은 태그가 여러 배치에 걸친 경우 포함).
## 이 조사의 결론
**231종을 그대로 옮기면 안 된다.** 검증이 붙인 위험 신호 빈도:
| 신호 | 언급 |
| --- | --- |
| `ORDER BY` 없이 `Rows(0)` | 79 |
| 결과가 비결정적 | 74 |
| 예외 경로 문제 | 76 |
| 문자열 결합(바인드 아님) | 47 |
| SQL 주입 구멍 | 28 |
| 데이터 조회 함수가 MessageBox 를 띄움 | 42 |
담당의에서 겪은 <b>정렬 없는 `Rows(0)`</b> 문제가 79곳에 더 있다.
그때 세어 보니 25,488건이 걸렸다 — 여기도 종당 같은 조사가 필요하다.
## 이식 원칙 (이 조사에서 나온 것)
1. **바인드로 바꾼다.** 문자열 결합 47곳은 그대로 옮기지 않는다.
값 출처가 로그인 정보라 위험이 낮아 보여도 구멍은 구멍이다.
2. **조회 함수는 대화상자를 띄우지 않는다.** 42곳이 그렇게 한다 —
미리보기는 사람이 응답할 수 없는 자리에서 돈다. 사유를 돌려주는 쪽으로 바꾼다.
3. **정렬 없는 `Rows(0)` 은 종당 판단한다.** 실제로 다건인지 세어 보고,
다건이면 결정적 규칙을 고르고 <b>레거시와 다르게 간다고 기록</b>한다.
4. **조회를 합칠 때 주의.** 같은 표를 쓰는 태그가 많지만 선택 규칙이 미묘하게 다르다.
합치면 값이 달라지는 경우가 검증에서 여러 건 나왔다.
## 검증이 잡은 레거시 결함 (표본)
- `ETC_로그인_전문의번호_Refer` — 컬럼명에 <b>후행 공백</b>(`Item("UidSpcLic ")`)이 있어
행이 있을 때 `ArgumentException` 이 난다. 형제 태그는 공백이 없다.
- `ETC_로그인_직급` — 값은 `UidNam` 을 담고 조건은 `UidCod` 로 건다.
실무상 0건이라 늘 빈칸이었을 것이다 — <b>고치면 없던 값이 갑자기 채워진다</b>.
- `ECT_로그인사용자싸인` — 매개변수 없는 프로퍼티에 인자를 넘겨 반드시 예외.
- `ETC_로그인_근무부서` — 2차 시도에서 쓰지 않는 바인드가 남아 ORA-01036 개연성.
- `ETC_병리판독의사싸인``SELECT UidNam` 한 컬럼만 뽑고 `Item("UidImgPth")` 를 읽는다.
## 검증이 추출을 반박한 예
`ETC_마취및소독간호사List` — 추출이 "0건이면 NullReferenceException"이라 했으나
검증이 `clsOracleDA.vb:789` 를 직접 읽고 <b>빈 DataTable 이 반환된다</b>고 반박했다.
실제 동작은 예외가 아니라 구분선 한 줄만 든 목록이다 — 이식 시 분기가 완전히 달라진다.
## 판정 표
| 판정 | 태그 | 이식 시 걸리는 것 |
| --- | --- | --- |
| ok | `ECT_로그인사용자싸인` | GetUidMst 내부가 ORDER BY 없이 oDT.Rows(0) 을 집는다(clsCommonLib.vb:10639) — 추출에 이 사실이 안 적혔다. UidCod+일자로 대… |
| ok | `ECT_로그인사용자싸인` | 추출의 sqlShape 문구 '0행이면 날짜 뺀 재조회 후 예외 throw' 는 호출부로 예외가 올라간다고 읽히지만, 실제로는 Get… |
| ok | `ETC_건강보험증번호` | PisInsCod=1 숫자 리터럴(컬럼이 문자형이면 인덱스 미사용/ORA-01722). PisInsSeq 가 같은 값 다건이면 정렬 tie 로 Rows(0) … |
| ok | `ETC_건강보험증번호` | TO_NUMBER(PisInsSeq) 는 PisInsSeq 에 숫자 아닌 값이 섞이면 ORA-01722 이고 이 함수엔 Try 가 없다. PisInsCod=1 은 컬럼이 VARCHAR… |
| ok | `ETC_로그인_근무부서_사용자명` | 근무부서와 똑같이 2회차에서 oPD 에 :sAdpDte 가 남는다. 이름으로 걸어 동명이인이면 UIDENDDTE/DEPENDDTE 만으로 갈려 … |
| ok | `ETC_로그인_근무부서_사용자명` | 동명이인이면 남의 부서가 나오고 ORDER BY 로도 못 가른다. 2차 시도의 잔여 :sAdpDte 문제 동일(ORA-01036 위험). 근무부… |
| ok | `ETC_로그인_근무부서` | 2차 시도(iCheck=1)에서 oPD 에 sAdpDte 가 남는데 SQL 엔 자리표시자가 없다. clsOracleDA.vb:661-668 이 사전의 모든 항목을 Par… |
| ok | `ETC_로그인_근무부서` | 2회차(iCheck=1)에서 oPD 를 비우지 않아 SQL 에 없는 :sAdpDte 가 파라미터로 남는다 — Oracle 바인드는 미사용 파라미터… |
| ok | `ETC_로그인_의사면허번호` | M_DepMst 를 기간 조건 없이 조인해 부서 이력만큼 행이 부풀고 ORDER BY 가 없어 Rows(0) 이 형식상 비결정적(값 자체는 … |
| ok | `ETC_로그인_의사면허번호` | 부서 이력이 다건이면 조인이 행을 불리고 ORDER BY 없이 Rows(0) → 비결정적. 이식 시 조인을 빼고 M_UidMst 단독 + 유… |
| ok | `ETC_로그인_전문의번호_Refer` | DataColumnCollection 조회는 대소문자는 무시해도 공백은 트림하지 않으므로 행이 있으면 ArgumentException 이 그대로 호… |
| ok | `ETC_로그인_전문의번호_Refer` | 행이 있을 때만 터지는 함수라 병원/계정에 따라 증상이 갈린다. 이식 시 이 예외를 재현할 이유가 없으니 컬럼명… |
| ok | `ETC_로그인_전문의번호` | 위와 같음. 특히 M_DepMst 조인 제거는 '부서 이력이 없는 사용자'에서 레거시와 결과가 갈린다 — 이식 결정 사항으… |
| ok | `ETC_로그인_전문의번호` | 의사면허번호와 동일 — 유효기간 없음, ORDER BY 없이 Rows(0), 불필요한 M_DepMst 조인이 행을 불린다. |
| ok | `ETC_로그인_직급` | UidCod 와 UidNam 이 우연히 같은 계정(숫자ID가 아닌 이름ID)에서만 값이 나온다. 이식 시 UidCod 로 고치면 지금껏 빈 … |
| ok | `ETC_로그인_직급` | 추출이 \ |
| ok | `ETC_로그인_직종` | M_UidMst 유효기간 조건 없음 + ORDER BY 없이 Rows(0). Catch 가 조용해서 이식 중 오류가 묻힌다 — 로그는 남길 것. 직급 … |
| ok | `ETC_로그인_직종` | ORDER BY 없이 Rows(0) — M_DtsMst 에 JOBCOD 기간이 겹치는 중복 코드가 있으면 비결정적. Catch 가 조용해서 조회가 깨져… |
| ok | `ETC_로그인사용자_병동_수간호사_싸인` | 15597 결합 구멍. 폴더 미생성 상태면 DownLoadFile 이 실패하고 MsgBox 만 뜬 뒤 존재하지 않는 경로를 반환한다. 수간호… |
| ok | `ETC_로그인사용자_병동_수간호사_싸인` | 조회만으로 이식이 끝나지 않는다(파일서버 1번 다운로드 + 로컬 캐시 경로 계약). DownLoadFile 실패 시 MsgBox 만 띄우… |
| ok | `ETC_로그인사용자_병동_수간호사` | 15535 SQL 주입 구멍(값 출처가 로그인 정보라 위험은 낮음). 코드 2종이 공존하면 1단이, 병동에 수간호사가 둘이면 … |
| ok | `ETC_로그인사용자_병동_수간호사` | UidDepCod 는 로그인 사용자 부서라 외부 입력은 아니지만 결합은 결합이다 — 이식 시 반드시 바인드로 바꿀 것. 155… |
| ok | `ETC_로그인사용자_병동간호사목록_InitalDatatable` | ORDER BY 가 없어 콤보 목록 순서가 비결정적이다(사용자 눈에 바로 보이는 차이라 이식본에 정렬을 넣으면 레거시… |
| ok | `ETC_로그인사용자_병동간호사목록_InitalDatatable` | 정렬이 없어 콤보 순서가 매번 달라질 수 있다(형제 목록들이 BSKOREA 에서만 정렬하는 것과도 어긋남). UidPstCod <= '8… |
| ok | `ETC_마취간호사List_InitialDatatable` | BSKOREA 외 정렬 없음 → 목록 순서 비결정. 만료 코드 혼입. 어시스트/소독/순환과 DtsDtlCod 만 다르니 한 조회로 합… |
| ok | `ETC_마취간호사List_InitialDatatable` | 기간(DtsStrDte/DtsExpDte) 조건이 없어 만료 코드가 섞여 나온다 — 공용 조회로 묶을 때 소독간호사List 만 기간 조건… |
| ok | `ETC_마취과의사List_InitialDatatable` | HspStrDte 는 M_DtlMst DtlCodVal 계열 문자열로 쓰이는 곳이 대부분(dtCommonLib.vb:1554,1641,2064 모두 문자열 비교)이라 여기의… |
| ok | `ETC_마취과의사List_InitialDatatable` | 추출이 '확정 못 함' 이라 남긴 타입을 확인했다: HspStrDte 는 `Public HspStrDte As String = \ |
| ok | `ETC_마취및소독간호사List_InitialDatatable` | 이 함수만 clsDAFactory 가 아니라 moDA.ExecuteQuery 를 쓴다 — 이식 시 커넥션/트랜잭션 경로가 다른 태그와 다를 수 있… |
| ok | `ETC_병리판독의사명` | ORDER BY 없이 Rows(0) — TLAB 소속 UidDtrYon='Y' 가 둘 이상이면 비결정적. 싸인·전문의번호와 각각 독립 조회라 세 태그… |
| ok | `ETC_병리판독의사명` | TLAB 소속 의사 다건이면 아무나 뽑힌다. 전문의번호·싸인 태그가 각자 같은 WHERE 로 따로 조회하므로 <이름·번호… |
| ok | `ETC_병리판독의사싸인` | 실질적으로 항상 Null/빈값인 태그다. 이식해서 <제대로 동작시키면> 지금까지 안 찍히던 싸인이 서식에 찍힌다 —… |
| ok | `ETC_병리판독의사싸인` | 행이 있으면 ArgumentException → 빈 Catch → String 함수가 Nothing 반환. 즉 실사용에서 항상 빈 값이고, 사용자에겐 아무… |
| ok | `ETC_병리판독의사전문의번호` | ORDER BY 없이 Rows(0) + 세 태그 개별 조회로 인한 인물 불일치(위 병리판독의사명 참고). M_UidMst 에 TLAB 의사가 없으면… |
| ok | `ETC_병리판독의사전문의번호` | ORDER BY 없이 Rows(0) 이고 명·싸인과 별개 조회라 세 값이 서로 다른 의사 것이 될 수 있다. 한 조회(SELECT UidNam, UidSpc… |
| ok | `ETC_산재지정번호` | 추출이 '읽지 않았다'고 남긴 GetCodInfDT 를 확인했다(dtCommonLib.vb:2751, piJoin=1=With_Join): `P_CodInf Cod LEFT JOIN M_UidMst Usr(Usr… |
| ok | `ETC_소독간호사List_InitialDatatable` | 3786 은 단순한 스타일 차이가 아니라 <오류 가능성>이다. 형제·수간호사 태그(15521)가 모두 문자열 비교를 쓰는 것… |
| ok | `ETC_소독간호사List_InitialDatatable` | 같은 파일의 다른 조회들이 DtsStrDte/DtsExpDte 를 TO_CHAR(SYSDATE,'YYYYMMDD') 나 YYYYMMDD 문자열과 비교하므로(예: 15521, 17272) … |
| ok | `ETC_수술_내시경간호사목록_InitalDatatable` | BSKOREA 는 ORA-00933 로 Catch→ErrorMessageBox→Nothing 이 될 것(실행 미확인). 이식본에서 ORDER BY UidDepCod, UidNam 으로 '고치… |
| ok | `ETC_수술_내시경간호사목록_InitalDatatable` | BSKOREA 에서 ORA-00933 로 터져 ErrorMessageBox 후 Nothing 이 될 것(실행 미확인). 이식 시 ORDER BY UidDepCod, UidNam 으로 고치면 … |
| ok | `ETC_수술간호사목록_InitalDatatable` | 'HIMCHAN_AA' To 'HIMCHAN_ZZ' 는 문자열 범위라 접미가 소문자·숫자면 벗어난다(같은 힘찬인데 15483 StartsWith 판정과 결과… |
| ok | `ETC_수술간호사목록_InitalDatatable` | HIMCHAN 은 ORDER BY 가 없어 순서 비결정적, BSKOREA 는 의사가 섞인다. 문자열 범위 비교는 프로젝트가 OptionCompare Binary … |
| ok | `ETC_수술간호사목록` | 두 태그를 한 조회로 합치면 HIMCHAN 계열에서 이 태그에 UidDtrYon='N' 이 붙어 의사가 빠진다 — 레거시는 여기선 의… |
| ok | `ETC_수술간호사목록` | 힘찬 계열에서 두 태그가 서로 다른 목록을 낸다 — 별칭으로 합치면 힘찬 사용자의 콤보에 의사가 섞여 들어간… |
| ok | `ETC_수술실간호사` | 별칭이 한글 컬럼('성명')이라 이식 시 대소문자·인코딩·따옴표 처리에 주의. ORDER BY 없이 Rows(0) 이지만 UidCod+기… |
| ok | `ETC_수술실간호사` | 한글 별칭('성명')은 이식 시 영문으로 바꿀 것. UidCod+유효기간이라 실무상 1행이지만 ORDER BY 는 없다. moWrkInfoBiz.Wrk… |
| ok | `ETC_수술일자_1_몇년2자리` | 동일. 4종이 같은 SQL 을 각각 실행하므로 이식 시 1회로 합칠 것 — 다만 4개 태그가 <같은 행>을 본다는 보장이 지… |
| ok | `ETC_수술일자_1_몇년2자리` | 위와 같음. 빈 값 가드는 있으나 길이 가드가 없어 OprDte 가 3자 이하면 ArgumentOutOfRange 가 그대로 올라간다(Try 없음)… |
| ok | `ETC_수술일자_1_몇년` | OprKey 가 시간순인지 미확인 — 시퀀스가 아니면 '최신 수술'이 아닐 수 있다. OprDte 가 4자리 미만이면 Substring 예외… |
| ok | `ETC_수술일자_1_몇년` | 추출이 안 적은 조건이 하나 있다: 행 채택 조건이 `Rows.Count > 0 AndAlso Rows(0).Item(\ |
| ok | `ETC_수술일자_2_몇월` | OprKey 정렬 의미 미확인. Substring 예외 가능. 4종 공유 조회로 묶을 것. |
| ok | `ETC_수술일자_2_몇월` | 위와 같음(빈 값 가드 있음, 길이 가드 없음, Try 없음). |
| ok | `ETC_수술일자_3_몇일` | OprKey 정렬 의미 미확인. Substring 예외 가능. |
| ok | `ETC_수술일자_3_몇일` | 위와 같음. OprDte 가 8자 미만이면 여기서 가장 먼저 터진다. |
| ok | `ETC_순환간호사List_InitialDatatable` | 기간 조건이 없어 만료 코드가 섞인다(소독간호사List 와의 비대칭). BSKOREA 외 순서 비결정적. 소독/순환 코드 뒤바… |
| ok | `ETC_순환간호사List_InitialDatatable` | 코드 뒤바뀜은 실데이터로 확정 전엔 <그대로> 옮길 것. 정렬 없음, 만료 코드 혼입. 마취및소독 태그가 이 코드를… |
| ok | `ETC_승인의사` | <DA 지뢰>: clsOracleDA.vb:655 가 모든 쿼리에서 \ |
| ok | `ETC_승인의사` | CTE 의 LEFT JOIN M_UidMst 는 SELECT/WHERE 어디에도 안 쓰이므로 '불필요' 하지만, LEFT JOIN 이 M_UidMst 기간 이력만큼 <행을 … |
| ok | `ETC_어시스트간호사List_InitialDatatable` | 기간 조건 없어 만료 코드 노출, BSKOREA 외 순서 비결정적. 'OPRCFMUID' + DtsDtlCod 를 인자로 받는 공용 조회로 묶을 때 <… |
| ok | `ETC_어시스트간호사List_InitialDatatable` | 만료 코드 혼입, BSKOREA 외 정렬 없음. 형제 넷 중 소독판만 유효기간을 거는 비일관성을 이식하면서 통일하면 목… |
| ok | `ETC_영상의학과의사싸인` | XRAY 의사 다건이면 남의 싸인이 서식에 찍힌다(ORDER BY 없음) — 목록 통틀어 가장 실무 피해가 큰 축. 빈 Catch 로 실… |
| ok | `ETC_영상의학과의사싸인` | 빈 Catch 라 예외 시 조용히 Nothing 반환(호출부가 널을 받는다). ORDER BY 없이 Rows(0) — XRAY 의사가 여럿이면 누구 서… |
| ok | `ETC_외출외박_확인간호사싸인` | 추출이 1단의 비결정성은 적었지만 2단 GetUidMst 도 ORDER BY 없이 Rows(0) 이라는 점(clsCommonLib.vb:10639)은 안 적혔다. 더 … |
| ok | `ETC_외출외박_확인간호사싸인` | 한 입원건(ComNum)에 외출외박 신청이 여러 건이면 ORDER BY 도 신청키·일자 조건도 없어 어느 건의 승인간호사인지 … |
| ok | `ETC_요양기관기호` | (1) '형제 10종'은 실제 12종이다 — 같은 sAdpDtm 블록을 가진 함수가 15813~16667 에 12개, 산재지정번호(15744)까지 세면 1… |
| ok | `ETC_요양기관명칭_병원대표자` | 병원명과 동일. |
| ok | `ETC_요양기관명칭_병원로고` | oDT 가 Nothing 이면 16559 는 IndexOutOfRange 가 아니라 NullReference. 다운로드가 실패해도 MessageBox 만 띄우고(16569) <없는 파… |
| ok | `ETC_요양기관명칭_병원명_영문` | 중복 바인드 블록은 무해하지만 같은 패턴이 팩스번호(16322)·전화팩스 2종(16182, 16252)·병원장(16525)·병원장귀하(16… |
| ok | `ETC_요양기관명칭_병원명` | GetCodInfDT 가 예외 시 Nothing 을 돌려주므로(dtCommonLib.vb 2886-2889) 15830 은 IndexOutOfRange 뿐 아니라 NullReference 도 난다. Com… |
| ok | `ETC_요양기관명칭_병원장` | 병원명과 동일. HspInsNam 이 '…병원'으로 끝나지 않는 기관이면 '…장'이 어색해지는데 레거시가 그대로 붙이므로 … |
| ok | `ETC_요양기관명칭_병원장귀하` | 병원명과 동일. 3종을 한 조회로 합치면 왕복 3회를 1회로 줄일 수 있다. |
| ok | `ETC_요양기관명칭_병원전화_팩스번호` | 병원명과 동일. 접두사/구분 공백이 서식에 그대로 찍히므로 이식 시 문자열을 글자 단위로 옮겨야 한다. |
| ok | `ETC_요양기관명칭_병원전화번호` | 병원명과 동일. 전화·팩스 합본 태그(16196)와 조회가 같으므로 한 번 조회로 공유 가능. |
| ok | `ETC_요양기관명칭_병원주소_영문` | 병원명과 동일. |
| ok | `ETC_요양기관명칭_병원주소` | 병원명과 동일(무검사 Rows(0), ORDER BY 없음, DepHspCod 조건 없음). 주소는 여러 줄일 수 있어 Trim 만으로는 서식 줄바… |
| ok | `ETC_요양기관명칭_병원직인` | blocker 의 '빈 로컬경로'는 두 갈래가 섞여 있다. 행이 없거나 SELYON 이 빈 경우만 \ |
| ok | `ETC_요양기관명칭_병원팩스번호` | 병원명과 동일. 16321-16323 에 중복 바인드 재시도가 있으나 ContainsKey 로 무시된다(추출에 미기재, 무해). |
| ok | `ETC_요양기관명칭_사업자등록번호` | 병원명과 동일. 번호 포맷(하이픈)이 DB 값 그대로 나간다. |
| ok | `ETC_요양기관명칭_영문_병원전화_팩스번호` | 병원명과 동일. +82 표기 시 지역번호 앞 0 을 떼지 않고 그대로 이어 붙인다 — 레거시 그대로 옮기면 '+82 - 02-…' … |
| ok | `ETC_의사List_이름순` | UidDepCod/UidHspCod 필터가 없어 전 병원 의사가 섞인다. 동명이인이 있으면 UidNam 만으로는 구분 불가 — 서식 선택 목… |
| ok | `ETC_재해발생일` | PisInsCod 가 문자 칼럼이라는 추정은 다른 코드가 뒷받침한다 — dtPatComList.vb 200/553/735, bzDataInterface.vb 752 에서 '1','2'… |
| ok | `ETC_전체진료과_영문명` | 한글명판과 동일. 한 조회로 DepKorNam, DepKngNam 을 같이 뽑아 공유하면 왕복이 준다. |
| ok | `ETC_전체진료과_한글명` | moWrkInfoBiz 가 Nothing 이면 16801 에서 NullReference 가 그대로 위로 튄다(의사List_이름순은 Nothing 검사를 한다). DepHspCod … |
| ok | `ETC_접속유저_부서번호` | moWrkInfoBiz.UidMst 가 Nothing 이면 17292 에서 NullReference. DepHspCod 조건이 없어 같은 UidDepCod 를 쓰는 진료과 행이 병원별… |
| ok | `ETC_제한향균제_InitialDatatable` | Try/Catch 는 있다(3673/3710) — 이 태그는 예외가 조용히 Nothing 이 된다. 병원별로 한글명/영문명이 갈리므로 서식 표… |
| ok | `ETC_진단검사의사명` | 3종(명 17557-17566 / 싸인 17598-17607 / 전문의번호 17662-17671)의 SQL 이 글자 단위로 동일함을 대조 확인 — 한 번 조회해 … |
| ok | `ETC_진단검사의사싸인` | 빈 Catch 라 예외 시 String 함수가 Nothing 을 반환한다 — 호출부가 .Trim 등을 하면 거기서 NullReference 가 난다. 다운로… |
| ok | `ETC_진단검사의사전문의번호` | 진단검사의사명과 동일. UidSpcLic 가 널인 의사가 Rows(0) 이면 빈 문자열이 찍힌다 — WHERE 는 UidLicNum 만 NOT NULL 을 요… |
| ok | `ETC_진료과별의사List_이름순` | 레거시 동작을 그대로 흉내낼 수 없는 유일한 태그다. 바르게 바인드해 옮기면 <레거시에서는 아무 값도 안 나오… |
| ok | `GetNurseEmrDT` | PIVOT 출력에는 한글 별칭 말고 ShtCod 도 함께 나오고 그 값이 그룹 키가 된다 — EmrShtCod 를 바인드로 고정하므로 1… |
| ok | `GetNurseShtCod_GCRCH` | 4종을 '병원별 후보 코드 배열'로 합치라는 제안은 타당 — GCRCH:{S113,S202,S469}, GNBEDRO:{S113,S062,S163}, SRH:{S113,S202,S511}, … |
| ok | `GetNurseShtCod_GNBEDRO` | 주석은 두 번째를 '간호정보조사지(소아)'라고 부르지만 코드가 S062 로 다르다 — 서식명 매핑을 코드가 아니라 … |
| ok | `GetNurseShtCod_SRH` | GetNurseShtCod 계열과 동일. 병원코드가 이 4개 중 어디에도 없으면 기본(GetNurseShtCod)으로 떨어지므로, 이식 시 매핑 … |
| ok | `GetNurseShtCod` | 3회 왕복을 EmrShtCod IN ('S113','S202','S163') 한 번 + CASE 우선순위 정렬로 합칠 수 있다. 집는 값이 WHERE 로 고정된 상수… |
| ok | `OCM_BMI` | iWeight/iHeight 가 String→Double 암시 변환(Option Strict Off). 값에 단위·콤마가 섞이면 InvalidCastException → MessageBox → \ |
| ok | `OCM_BST_LAST` | 키 정책(ComNum vs ChtNum)을 이식 전에 확정해야 한다. 머리둘레_LAST 도 같은 편이므로 '오타 1건'이 아니라 '두 갈래가… |
| ok | `OCM_BST` | EmdBst 는 문자열 그대로 나온다(단위·형식 정규화 없음). 동일 EmdDte/EmdTime 동률 시 비결정. |
| ok | `OCM_Cosign_싸인` | 세부 1건: 다운로드 호출은 지역 clsFileTransfer 가 아니라 <멤버 moFileTransfer>(14909 지연 생성)다 — 상태를 공유하는 … |
| ok | `OCM_Cosign_의사명` | 싸인 태그와 공용 조회로 합칠 때 주의: 싸인 쪽은 Try/Catch 가 없고 이쪽은 있다. 합치면 예외 전파 동작이 한쪽에… |
| ok | `OCM_FollowUp` | 1039 원문이 \ |
| ok | `OCM_LMP` | DataTable 인덱서는 대소문자 무시라 지금은 동작하지만, 이식 대상이 대소문자를 구분하는 매핑(예: 엔티티 프로퍼… |
| ok | `OCM_SPO2_LAST` | ASC 판(ComNum)과 DESC 판(ChtNum)의 모집단이 다르다. 같은 화면에 둘 다 찍으면 '첫 SpO2'가 '마지막 SpO2'보다 나중 내원 … |
| ok | `OCM_SPO2` | EmdDte/EmdTime 동률 시 비결정. 값은 문자열 그대로. |
| ok | `OCM_VITAL접수일시_LAST` | ExecuteQuery 가 예외를 다시 던지므로(clsOracleDA.vb 711) Try/Catch 없는 이 태그는 서식 생성 전체를 중단시킬 수 있다. 정… |
| ok | `OCM_감염분류_감염정보` | sAcpDte 는 SystemDate(8자리) — 감염정보 태그와 같다. 값이 없으면 IcnInf 가 NULL 이라 Rows(0).ToString 이 \ |
| ok | `OCM_감염정보` | 오류 경로가 MessageBox 2번이다 — DB 계층이 먼저 clsLogWriter.ErrorMessageBox 를 띄우고(708) 예외를 다시 던진 뒤 태그의 C… |
| ok | `OCM_계산유형` | 세부 1건: 추출의 sqlShape 는 \ |
| ok | `OCM_당뇨식칼로리_List` | ExecuteQuery 가 예외를 재던지므로(711) ORA-01722(ImlCal 비숫자) 가 서식 생성 전체를 끊는다. LIKE '%당뇨%' 는 인덱스를 … |
| ok | `OCM_당일식이` | <덫>: DB 계층이 실행 직전 psQuery.Replace(\ |
| ok | `OCM_맥박_LAST` | 환자 전체 이력에서 최신값이므로 다른 병원/과 내원 값이 섞여 나온다. |
| ok | `OCM_맥박` | 동률 시 비결정. 값은 문자열 그대로. |
| ok | `OCM_머리둘레_LAST` | 머리둘레와 동일. 다만 '다른 _LAST 형제와 달리 ComNum' 이라는 서술은 BST_LAST 도 ComNum 이라 '유일'은 아니다. |
| ok | `OCM_머리둘레` | HC 로 고치면 종이에 없던 값이 생긴다. 더 나쁜 건 <현장에서 MessageBox 가 뜬다>는 점 — 값이 있는 환자에서만 팝… |
| ok | `OCM_몸무게_LAST` | BMI 와 공용화할 경우 BMI 는 값이 없을 때 즉시 \ |
| ok | `OCM_몸무게` | BMI 1차 조회와 표·컬럼이 같고 키·정렬만 다르다 — 공용화하면 두 태그가 서로 다른 값을 내야 한다는 사실을 … |
| ok | `OCM_발병일` | 기준일이 DB SYSDATE 라 서식을 나중에 다시 뽑으면 값이 달라질 수 있다(유효기간 이탈). OnsStrDte 가 8자 미만이면 Sub… |
| ok | `OCM_병실_Refer` | 6896 은 .ToUpper 없이 = \ |
| ok | `OCM_병실` | 세부 1건: PatTyp='I' 비교는 대소문자 그대로인데 'O' 비교만 .ToUpper 다(6808 vs 6822) — 소문자 'i' 가 들어오면 마지막 E… |
| ok | `OCM_병실인실` | 세부 1건: keys 필드는 CowStrDtm 조건을 '조인'에 넣어 적었지만 원문은 WHERE 절이다(7080) — INNER JOIN 이라 결과는 같고… |
| ok | `OCM_병실차액` | SgaHthIns 만 집으므로 의료급여·자보·산재 환자에게 <건보 차액>이 찍힌다. 비급여처방 태그는 같은 M_SgaMst 에서 … |
| ok | `OCM_보험유형_Refer` | blocker 가 비어 있지만 본판과 <똑같은> 14자리 SystemDateTime 문제를 그대로 갖고 있다(5252) — 본판에만 blocker 를 단 … |
| ok | `OCM_보험유형` | 계산유형과 동일한 14자리/8자리 BETWEEN 문제. 선행 조회를 공용화할 때 계산유형은 CoiCalCod 까지 쓰고 이 태그는 안… |
| ok | `OCM_비만도` | 성별이 'F'/'M' 이 아니면 Select Case 가 아무것도 안 해 StdWgt=0 → 0 나눗셈. Option Strict Off 의 Double 나눗셈이라 예외가 … |
| ok | `OCM_상병명_세로_Refer` | OkdDtrNam 컬럼은 헬퍼 Case Else 에만 존재한다 — KIMEYE/NYJB/SYBS/HIMCHAN_BS/HIMCHAN_CW/SPHH 에서 JINDAN-OKDKCDNAM='OKDDTRNAM' 이면 oD… |
| ok | `OCM_상병코드_상병명_Refer` | OkdDtrNam 컬럼이 없는 병원(KIMEYE/NYJB/SYBS/HIMCHAN_*/SPHH)에서 JINDAN='OKDDTRNAM' 이면 예외. 헬퍼 결함 상속. |
| ok | `OCM_상병코드_상병명_부상병_Refer` | 같은 Refer 계열인데 태그마다 상병명 원천이 달라진다(세로/코드상병명은 OkdDtrNam 을 보고 주/부상병은 안 본다). … |
| ok | `OCM_상병코드_상병명_부상병` | 헬퍼 결함 상속. ORDER BY OkdDspSeq 단독인데 번호를 붙여 출력하므로 순서 흔들림이 눈에 보인다 — 2차 정렬키 필수. |
| ok | `OCM_상병코드_상병명_주상병_Refer` | 'T 2회만 조회하고 메모리에서 갈라도 동일' 이라는 제안은 UNION 둘째 가지 때문에 완전 동치가 아니다 — M/S 필터… |
| ok | `OCM_상병코드_상병명_주상병` | 주상병 복수면 한 줄에 붙어 나오고 전부 '1.' 이 된다는 지적 유효. 헬퍼 결함 상속. |
| ok | `OCM_상병코드_상병명` | 헬퍼 결함 상속: NYJB 는 본인상병 필터가 UNION 한쪽에만 걸려 남의 상병이 섞일 수 있고, 병원별로 KcdKorNam 이 계산… |
| ok | `OCM_수납일시_Refer` | P_CoiInf 를 표 목록에 추가할 것(위와 동일). 두 Rows(0) 비결정 위험 그대로. |
| ok | `OCM_수납일시` | 미확인이라 적힌 GetCoiInfDT 를 확인했다(dtCommonLib.vb 2914-2955): 표는 <P_CoiInf>, WHERE CoiComNum=:psComNum AND :psAdpDte BETWEEN CoiS… |
| ok | `OCM_수술마취의사` | ANEUIDCOD 는 LISTAGG 끝 콤마를 SUBSTR 로 잘라낸 값(13296)이라 마취의가 여러 명이면 콤마 나열 문자열 한 덩어리가 한 … |
| ok | `OCM_수술마취의사싸인_수술접수연동` | 실패해도 로컬 경로를 반환하고 Catch 가 비어 Nothing 이 나갈 수 있다. 같은 OprKey 에 ANEUIDCOD 행이 여럿이면 어느 마… |
| ok | `OCM_수술명칭` | ORDER BY OprDte 단독 — 같은 날 수술이 여럿이거나 OprTyp 행이 여럿이면 줄 순서 비결정. OprCodNam 뒤 공백 1칸이 붙는 … |
| ok | `OCM_수술일자_마지막수술` | OprKey 가 유일키라는 전제에 의존한다(실DB 확인). 수술일자 ②③과 조회 1회로 합치는 제안은 타당. |
| ok | `OCM_수술일자` | 'HIMCHAN_AA' To 'HIMCHAN_ZZ' 는 VB 문자열 범위 비교라 StartsWith(\ |
| ok | `OCM_수술진단명` | '수술소독간호사와 다른 점' 이라는 대비는 근거가 없다 — 그쪽도 빈 SQL 가드(clsOracleDA 651-653) 때문에 예외 없이 \ |
| ok | `OCM_수술집도의_그룹진료과` | 유효기간을 수술일이 아니라 SYSDATE 로 걸어 퇴사 집도의·폐과 진료과 건이 INNER JOIN 에서 통째로 사라진다는 지적… |
| ok | `OCM_수술집도의_싸인` | OprStt 미필터·SYSDATE 유효기간·같은 OprKey 내 복수 집도의 비결정은 그대로. 다운로드 실패해도 로컬 경로를 반환… |
| ok | `OCM_수술집도의_진료과` | 그룹진료과·싸인과 동일한 3대 위험(OprStt 미필터, SYSDATE 기준 유효기간, 같은 OprKey 내 복수 집도의). |
| ok | `OCM_수술처치` | Data2Format 에 OprDte 를 Substring 없이 통째로 넘긴다(3507) — OprDte 가 8자리라는 전제. ORDER BY OprDte 단독이라 같은 날 수… |
| ok | `OCM_신환_초진일_Refer` | GetCodInfDT 특성(P_CodInf 전체, CodMtiSeq 무필터, ORDER BY CodStrDtm, 0행이면 2199 예외)은 위와 동일. 두 태그는 차트번호·기… |
| ok | `OCM_신환_초진일` | 미확인이라 적힌 GetCodInfDT 를 확인했다(clsCommonLib 10498-10502 → dtCommonLib 2751-2884): piJoin=0, psAdpDtm=\ |
| ok | `OCM_알러지_List` | AlgAlgCtg 가 항상 붙으므로 sAlg 가 완전히 비는 경우는 드물지만, Nothing 반환 경로는 이식 시 명시적으로 \ |
| ok | `OCM_알러지_기타` | 카테고리 한글 하드코딩이라 M_DtsMst/S_AlgInf 실제 값과 어긋나면 조용히 빈 값. 5종 통합 조회로 바꾸려면 LISTAGG 를 … |
| ok | `OCM_알러지_약물` | INNER JOIN 으로 미등록 코드 누락 공통. sAcpDte=SystemDate 라 과거 내원 재출력도 오늘 기준 유효 코드로 조인한다. |
| ok | `OCM_알러지_음식` | 위와 동일. |
| ok | `OCM_알러지_조영제` | 위와 동일. 카테고리를 파라미터로 받는 조회 하나로 합치는 것이 옳다. |
| ok | `OCM_알러지` | INNER JOIN 때문에 ALLERGY 코드마스터에 없는 AlgAlgTyp 이 통째로 빠진다는 지적이 핵심. sAcpDte 는 접수일이 아니라 Syste… |
| ok | `OCM_영상촬영이력_Refer` | 같은 날 촬영 순서 비결정, 예외 시 Nothing 반환. 내원번호만 파라미터화하면 조회 100% 공유. |
| ok | `OCM_영상촬영이력` | ORDER BY 가 별칭(SUBSTR 8자리)에 걸리므로 같은 날 촬영 순서는 비결정 — 2열 배치라 종이 모양이 달라진다. 2차 정… |
| ok | `OCM_외래실진료일` | 미사용 바인드가 치명적일 수 있다: clsOracleDA 는 파라미터를 걸러내지 않고 그대로 Add 한 뒤 BindByName=True 로 실행… |
| ok | `OCM_외래실진료일수_Refer` | Non_Join 은 CodMtiSeq 를 안 거르므로 MtiSeq<>0 행까지 섞여 들어온다 — 비Refer 판(With_Join, MtiSeq='0')과 후보 집합 자체가… |
| ok | `OCM_외출예상시작일시` | 필터가 실제값 컬럼(OutAdmStrDtm)만 보므로 예상값만 등록된 행은 아예 후보에서 빠진다(정렬은 NVL, 필터는 아님) —… |
| ok | `OCM_외출예상종료일시` | 두 태그를 한 조회로 합칠 때, 필터가 여전히 OutAdmStrDtm 기준이라는 점을 그대로 유지해야 한다. 반환은 포맷 없는… |
| ok | `OCM_외출외박신청일` | ORDER BY 없이 Rows(0) 이라 한 내원에 외출 이력이 2건 이상이면 값이 흔들린다 — 이식 시 CowStrDtm 정렬을 못 박을 것.… |
| ok | `OCM_외출외박종료일` | 신청일과 같은 두 문제를 그대로 승계한다 — ORDER BY 부재로 인한 비결정성, 그리고 '없음' 을 현재시각으로 채우… |
| ok | `OCM_응급증상여부_NO` | 주입 경로가 두 곳(ComNum, DtlCod 목록). 이식 시 반드시 바인드로 바꿀 것. ORDER BY 없이 Rows(0).OdrInsYon 을 보므로 응급 … |
| ok | `OCM_응급증상여부_YES` | NO 판과 조회가 동일하므로 1회 조회로 두 태그를 채울 수 있으나, NO 판에만 있는 SJRCH 특례(행 유무 판정) 때문에 … |
| ok | `OCM_입원과_Refer` | 통합 시 파라미터 두 개(ComNum, ComAcpDtm)만 갈아 끼우면 되지만, 헬퍼가 기간·CodMtiSeq='0' 로 좁힌다는 사실을 유지해… |
| ok | `OCM_입원과_한글명칭` | 입원과와 한 조회를 공유하고 컬럼만 달리 고르면 되지만, 20220701/DepOfcNam 규칙이 이 태그에는 <없다>는 점을 잃지 … |
| ok | `OCM_입원내원일시` | 값이 12자리 미만이거나 DBNull 이면 Substring 이 던지는데 감싸는 Try 가 없어 호출자까지 예외가 올라간다 — 미리보… |
| ok | `OCM_입원내원일자_Refer` | 진짜 분기는 병원별 <날짜 서식>과 '입원일 때만 출력' 조건이 병원마다 다르다는 점이다. ComPatTyp 비교가 Option Stri… |
| ok | `OCM_입원내원일자` | 일시 판과 자르는 길이만 다르므로 한 값에서 파생시키면 된다. Try 부재로 예외가 그대로 전파되는 점 동일. |
| ok | `OCM_입원시간` | Substring(8,4) 라 값이 12자리 미만이면 예외 → Catch 에서 MessageBox 가 뜬다. 미리보기 이식에서는 이 MessageBox 를 로그… |
| ok | `OCM_입원의사_Refer` | 비Refer 판과 한 벌로 묶을 수 있으나, psAdpDtm 을 자르지 않고 전문으로 넘기는 관례를 그대로 유지해야 결과가 같… |
| ok | `OCM_입원일시_영문` | 전문을 넘기는 BSKOREA·KIMEYE·JEGG 갈래는 '08-19-2026 1430' 처럼 시각이 콜론 없이 붙어 나온다 — 이식하며 'MM-DD-YYYY' … |
| ok | `OCM_입원일시` | 기본 갈래만 시각이 없으므로 서식 칸 폭·정렬이 병원별로 달라진다. 앞 두 갈래는 자르지 않은 전문을 넘기므로… |
| ok | `OCM_입원일자_낮병동` | ORDER BY 없이 Rows(0) 이라 낮병동 등록이 여러 건 걸리면 비결정적 — 이식 시 DcpStrDte 정렬을 못 박을 것. 7286 은 oDT.R… |
| ok | `OCM_재원일수_Refer` | Case \ |
| ok | `OCM_재원일수_낮병동` | ORDER BY 없이 Rows(0) 두 컬럼을 쓰므로 낮병동 등록이 여러 건이면 시작·종료가 <다른 행에서> 오지는 않지만 어느 … |
| ok | `OCM_재원일수_단입법` | 7976 을 레거시 그대로 베낄지 고칠지 반드시 명시적으로 정할 것 — 고치면 그 병원군에서 값이 하루 줄어드는 회… |
| ok | `OCM_재원일수` | 추출이 Select Case 의 Case \ |
| ok | `OCM_적용진료과` | AdpDtm 이 12자리인데 DepStrDte/DepEndDte 는 8자리 날짜라 문자열 BETWEEN 이 상한 경계(마지막 날)에서 어긋날 수 있다 — … |
| ok | `OCM_전과내역_Refer` | 레거시대로 두면 이 태그는 영원히 OCM_전과내역 과 같은 값을 낸다 — 고칠지 말지 결정하고, 고치면 '의도적 차… |
| ok | `OCM_전과내역` | 이 함수에는 Try/Catch 가 아예 없다(1103-1158) — ExecuteQuery 나 Data2Format 예외가 호출자까지 그대로 올라간다. 추출에 … |
| ok | `OCM_조정체중_LAST` | 성별 미상에 '0' 이 찍히는 폴백은 종이에 그럴듯한 가짜 값을 남긴다 — ''로 바꿀지 판단 필요. 필터가 ChtNum 이라… |
| ok | `OCM_주상병명_주상병코드` | AppendLine 이 아니라 Append 라 주상병이 2건 이상이면 값이 붙어 나온다 — 레거시 버그로 보이나 그대로 옮길지 결… |
| ok | `OCM_주상병명` | 주상병이 2건 이상 등록된 내원에서는 Rows(0) 이 비결정적이다. 14238 은 주입 경로(NYJB 한정)이고, 그 갈래는 UIDDEPCOD… |
| ok | `OCM_주상병코드` | OKDENTDTR 보간은 이식 시 반드시 바인드로 바꿔야 한다(SQL 주입). NYJB 는 로그인 사용자에 따라 태그 값이 바뀌므로 … |
| ok | `OCM_주상병코드` | 주상병명·주상병명_주상병코드 와 조회가 완전히 같으니 한 DataTable 로 세 태그를 채우는 것이 맞다. 다만 KIMEYE … |
| ok | `OCM_진단서전문그룹과목_Refer` | 현재 내원이 입원·참조 내원이 외래(또는 그 반대)면 ComPrgStt 를 엉뚱한 축으로 읽어 GetCodInfDT 인자가 뒤바뀐다. … |
| ok | `OCM_진단서전문그룹과목` | 1차가 0행이면 11315 Rows(0) 가 Try/Catch 없이 IndexOutOfRange 로 터진다. 유효일자가 내원일이 아니라 오늘이라 과거 서식… |
| ok | `OCM_진단서청구진료과목_로그인` | 0행이면 4841 에서 IndexOutOfRangeException 이 Try/Catch 없이 밖으로 나간다 — 이식 시 반드시 가드를 넣거나 예외 전파를… |
| ok | `OCM_진료과_Refer` | 비-Refer 와 psAdpDtm 자릿수가 달라 같은 내원을 봐도 GetCodInfDT 결과가 갈릴 수 있다 — 조회를 공유하려면 이 인자를… |
| ok | `OCM_진료과_그룹코드` | 진료과와 조회를 공유하되 픽만 달리하면 되지만, 괄호 조립이라 DepGrpCod 가 NULL 이면 \ |
| ok | `OCM_진료과_영어` | DepEngNam 이 비면 빈 문자열이 그대로 나간다(폴백 없음). M_DepMst 무정렬 Rows(0). |
| ok | `OCM_진료과_전화번호` | M_DepMst 유효구간이 겹치면 Rows(0) 비결정적 — 추출대로 blocker 유효. 입원 ILV 는 부서 선택엔 ComLevDtm 을 쓰면서 부… |
| ok | `OCM_진료과` | M_DepMst 무정렬 Rows(0). psAdpDtm 미절단은 dtCommonLib.vb 2784 의 sCurDte=Substring(0,8)에는 영향이 없고 2875 의 :psAdpDtm BETWEEN Cod… |
| ok | `OCM_진료과대외명칭_Refer` | 대외명칭과 동일. 조회 공유 시 sAcpDte/sDepCod 출처만 분리하면 된다. |
| ok | `OCM_진료과대외명칭` | DepOfcNam 미설정 병원에서 빈 문자열이 그대로 서식에 찍힌다 — OCM_진료과의 KorNam 폴백과 혼동하면 안 된다. 무정… |
| ok | `OCM_진료과약어명칭` | 약어명칭 미설정 시 빈 문자열. 무정렬 Rows(0). |
| ok | `OCM_진료기간_진료과_Refer` | 2698 도 ComAcpDtm 을 자르지 않고 넘긴다. 2699 는 oCodInfDT null/0행 검사 없이 Rows(0) 을 집어 Catch 로 삼켜진다. |
| ok | `OCM_진료기간_진료과` | 2597 의 psAdpDtm 이 Substring 없이 ComAcpDtm 원문 그대로 넘어간다 — 추출은 이 미절단을 OCM_진료과·_그룹코드만의 특… |
| ok | `OCM_청구진료과목_Refer` | 전문그룹과목_Refer(11338)와 똑같은 PatTyp 오용이라 두 곳을 한꺼번에 고치거나 한꺼번에 보존해야 한다. GetDtlCodNam … |
| ok | `OCM_청구진료과목` | GetDtlCodNam 은 유효일자 무필터·무정렬이라 같은 DtlCod 의 세대가 여러 개면 값이 흔들린다. 결합되는 psColNam/psOrderB… |
| ok | `OCM_체온_LAST` | 키_LAST 와 동일. |
| ok | `OCM_체온` | 키와 동일. 값 포맷이 없어 DB 원문(예: '36.5 ')이 그대로 서식에 들어간다 — Trim 도 하지 않는다. |
| ok | `OCM_초진일_발병일_Refer` | 초진일_발병일과 동일한 Non_Join·CodMtiSeq 무필터 비결정성을 물려받는다. 조회 공유는 가능하나 sDepCod 산출 인자만… |
| ok | `OCM_초진일_발병일` | Non_Join 갈래에는 CodMtiSeq='0' 필터가 없다(그건 With_Join 2831 에만 있다). ORDER BY CodStrDtm 이 유일키가 아니어서 같은 Cod… |
| ok | `OCM_최초내원일` | KIMEYE 갈래는 ORDER BY 1순위가 ComNfsTyp 라 '최초내원일'이 실제 최초가 아니다 — 재진('2')이 신환('1')보다 먼저 있어… |
| ok | `OCM_키_LAST` | ComNum/ChtNum·ASC/DESC 두 축으로 8종을 파라미터화하는 건 가능하지만, 주석 처리된 결합 줄을 되살리면 즉시 주입 구… |
| ok | `OCM_키` | EmrSttFlg 조건이 INNER JOIN ON 에 있어 논리적으로는 WHERE 와 같지만, 이식하며 LEFT JOIN 으로 바꾸면 의미가 달라진다. R… |
| ok | `OCM_퇴원과_Refer` | 6313 예외는 Try/Catch 로 삼켜져 MessageBox 를 띄우고 \ |
| ok | `OCM_퇴원과_한글명칭` | 세 자매(퇴원과/한글명칭/_Refer)의 픽만 다르고 조인 분기는 두 종이라 하나로 합칠 때 어느 조인 규칙을 표준으로… |
| ok | `OCM_퇴원과` | ComLevDtm 이 공백/NULL 이면 BETWEEN 이 NULL 이라 0행 → \ |
| ok | `OCM_퇴원분석_수술처치명` | 조립 전 oSB.Clear()(13944) 로 쿼리 문자열을 지워버리므로 예외가 조립 단계에서 나면 ErrorMessageBox 에 쿼리가 아니라 … |
| ok | `OCM_퇴원약_New` | 음수 iPadLen 위험은 추출이 지목한 10786/10805 보다 10822·10840·10857 이 더 크다 — 이 세 줄은 <절단된 문자열이 아니… |
| ok | `OCM_퇴원약` | 패딩 폭이 추출 표기(20/50/5/5/5)와 정확히 같지는 않다 — 실제는 PadRight(20-LenK) 처럼 <목표폭에서 바이트길이를 뺀 … |
| wrong | `ETC_마취및소독간호사List_InitialDatatable` | 레거시가 0건일 때 조용히 '------' 만 든 콤보를 내놓는다는 사실을 모르고 이식하면 빈 목록 처리 분기가 달라진… |
| wrong | `ETC_산재지정번호` | CodStrDtm 오름차순 Rows(0) 을 모르고 \ |
| wrong | `ETC_외출외박_확인원무과싸인` | '기간 밖이면 기간 조건을 빼고 다시 찾는다'로 옮기면 레거시가 빈 값을 주던 자리에 싸인이 찍힌다 — 출력이 … |
| wrong | `GetOprInfDT_OKDMain` | GetOprInfDT 와 동일(집계 함수 오식, 인접 중복만 지우는 regexp_replace, ROWNUM=1, DA 계층의 'WITH ' 치환). 추가로 인라인 … |
| wrong | `GetOprInfDT` | 서술대로 여섯을 다 LISTAGG 로 옮기면 DX/OPNAME/OKD/OPRCOD 가 단일값 대신 콤마 목록이 되어 서식 값이 달라진다. LISTAGG… |
| wrong | `OCM_부상병명` | NYJB 의 OKDENTDTR 조건은 첫 블록에만 붙고 UNION 둘째(구제) 블록에는 없다(14430-14455) — 유효기간을 못 맞춘 상병은 … |
| wrong | `OCM_비급여처방` | SgaCA1/CA2 갈래는 <SgaCfg 값이 설정되고 그 환자의 CoiCalCod 가 일치할 때만> 들어간다 — '설정된 병원에서는 통째로 … |
| wrong | `OCM_상병명_상병코드_Refer` | 본판과 _Refer 가 <같은 병원, 같은 갈래>에서 문구만 다르므로 두 태그를 한 종이에 쓰면 같은 상병이 '확증'과 '확… |
| wrong | `OCM_상병명_상병코드` | UIDCOD 는 반드시 바인드로. NYJB 조건이 첫 블록에만 붙어 UNION 둘째 블록으로 다른 의사 상병이 되살아난다. AppendLin… |
| wrong | `OCM_상병명_세로` | SYBS 는 KcdKorNam 에 이미 '(확)(부위)' 가 박혀 나오는데 클라이언트가 또 '(확진)' 을 붙여 이중 표기된다. HIMCHAN_BS/CW … |
| wrong | `OCM_수술마취의사싸인` | 이식 시 5회 재시도를 구현하면 레거시와 달라진다(2회차는 oFileHashtable.Add 중복키 예외라 애초에 불가능). UidImgPth … |
| wrong | `OCM_수술소독간호사` | PURME 가지가 ORDER BY 없는 Rows(0) 이라 Z20G 오더 복수 시 비결정이라는 지적은 유효. 이식 시 병원 분기만 지키면 되… |
| wrong | `OCM_외래실진료일_과목별` | 폴백(②)과 최종일(③) 양쪽에 상한이 없으므로 미래 예약 내원이 있으면 조회 구간이 통째로 미래까지 벌어진다 … |
| wrong | `OCM_외래실진료일수` | 1724 ORDER BY OcmAcpDtm 은 두 표 어디에도 없는 컬럼이라 ORA-00904 로 Catch(1757) 에 떨어져 MessageBox 후 '' 를 낼 공산이 크… |
| wrong | `OCM_임신주기` | sObgYga 가 'EDD'·'LMP' 어느 쪽도 아니면 어느 If 에도 안 걸려 함수 끝까지 흘러 Nothing(빈 문자열)을 낸다 — 이식 시 … |
| wrong | `OCM_입원과` | blocker ① 을 그대로 믿고 'CodStrDtm DESC 최신' 같은 규칙을 새로 넣으면 레거시와 값이 달라진다 — 필요한 것은 정… |
| wrong | `OCM_입원의사` | blocker ① 을 근거로 정렬 규칙을 새로 도입하면 레거시와 값이 갈린다. 실제로 봐야 할 것은 psAdpDtm 이 (0,12) 로 자… |
| wrong | `OCM_진단서전문과목_Refer` | 4772-4778 을 '고쳐서' 옮기면 로그인 사용자 부서로 나오던 기존 서식 출력이 바뀐다 — 출력 회귀. 그대로 옮기면 … |
| wrong | `OCM_진단서전문과목` | 분기가 빠진 채 이식하면 재원환자(ILV 아님)에서 GetCodInfDT 에 SystemDateTime 을 넘겨야 하는데 내원일을 넘기게 되어 … |
| wrong | `OCM_진료기간_Refer` | GUPOSS 병원에서 참조내원 진료기간은 레거시가 Case Else 로 떨어져 \ |
| wrong | `OCM_진료기간` | 이 추출대로 옮기면 SRH·GUPOSS·SRCH 병원의 진료기간 문자열이 레거시와 달라진다. 특히 SRCH 는 퇴원오더가 없는 재… |
+578
View File
@@ -0,0 +1,578 @@
[
{
"tag": "ETC_로그인사용자_서명",
"kind": "needs-lookup",
"row": "",
"column": "UidImgPth",
"formula": "path = clsCommonLib.GetUserInfo().UidImgPth; if (path == \"\") return \"\"; local = InstallPath + \"\\\\Tmp\\\\DownLoadedFiles\\\\Signature\\\\\" + path.Split(\u0027\\\\\u0027).Last(); FileTransfer.DownLoadFile(1, path, local); return local;",
"evidence": "bzDataInterface.vb:16736-16795 (16741 다운로드 폴더, 16747 GetUserInfo().UidImgPth, 16758-16762 경로 조립, 16764-16767 DownLoadFile, 16781 Return sLocalPath)",
"confidence": "medium",
"blocker": "clsCommonLib.GetInstance.GetUserInfo (UidImgPth), clsCommonLib.GetInstance.GetInstallPathFromServerInfoFile, clsCommonLib.GetInstance.CreateDirectory, M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 파일서버 다운로드가 필요해 지금은 못 옮긴다",
"note": "값이 아니라 \u003c로컬에 내려받은 이미지 파일 경로\u003e를 돌려준다(TagPreviewCatalog 의 Image 계열). 레거시 이상 세 가지를 그대로 적는다: (1) 16753 For 0..4 루프가 16771-16773 의 항상-참 조건 때문에 첫 회전에서 반드시 Exit For 한다 — 재시도가 아니라 1회 실행이다. (2) 16758-16762 은 \"|\" 를 붙였다가 바로 잘라내는 무의미한 왕복이다. (3) 16787 의 두 번째 Return sLocalPath 는 도달 불가다. 실패해도 MsgBox(16766, 16778)만 띄우고 경로를 그대로 돌려준다.",
"action": "UidImgPth 를 읽어 파일서버에서 내려받고 로컬 경로를 돌려주는 경로를 새로 만든다 — 환자 문맥과 무관하니 세션 해석기 쪽이다."
},
{
"tag": "OCM_OPNAME",
"kind": "needs-lookup",
"row": "",
"column": "OPNAME",
"formula": "dt = GetOprInfDT(); return dt?.Rows.Count \u003e 0 ? dt.Rows[0][\"OPNAME\"].ToString().Trim() : null;",
"evidence": "bzDataInterface.vb:13099-13109 (13101 GetOprInfDT, 13105 Rows(0).Item(\"OPNAME\").ToString.Trim)",
"confidence": "medium",
"blocker": "GetOprInfDT (bzDataInterface.vb:13203) — 수술 정보 SQL 을 먼저 옮겨야 한다",
"note": "OPNAME 별칭은 S_OprTyp 를 OprTyp=\u0027OPNAME\u0027 으로 LEFT JOIN 한 MAX(OprCodNam)(13203+100, 13203+111~113). OCM_DX 와 똑같이 Else 가 없어 빈 결과에서 null 이 나간다.",
"action": "수술정보 조회(GetOprInfDT)를 옮기고 첫 행 OPNAME.Trim 을 돌려주는 매핑을 넣는다 — 행이 없으면 빈 값."
},
{
"tag": "OCM_STFDTR",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp==\"I\" ? (ComInf.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); if(codDT.Rows.Count==0) return \"\"; var stf = codDT.Rows[0][\"CodStfDtr\"]; if(string.IsNullOrEmpty(stf)) return \"\"; var uid = GetUidMst(stf, PatTyp==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?[\"UidNam\"].Trim() ?? \"\";",
"evidence": "4026-4067 (분기 4031, GetCodInfDT 4033/4035/4049, CodStfDtr 4039/4051, GetUidMst 4040/4052, UidNam 4062)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) — 둘 다 문맥의 다섯 행이 아니라 별도 질의다(clsCommonLib.vb 10510 · 10609). clsCommonLib.SystemDateTime 도 필요.",
"note": "형제 태그(OCM_담당의사*)와 달리 CodDtrCod 가 아니라 CodStfDtr 를 쓴다. 또 형제들에 없는 빈값·Rows.Count 검사가 있다. 기준일자가 입원 분기는 CodEndDtm 앞 8자, 외래 분기는 ComAcpDtm 앞 8자로 서로 다르다(4040 의 주석 처리된 옛 코드가 그 흔적).",
"action": "CodInf 첫 행의 CodStfDtr(비면 빈 값)로 M_UidMst 를 찾아 UidNam.Trim 을 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_User진료과",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "if (WrkInfo.UidMst[\"UidDtrYon\"].Trim()==\"Y\") return (HspCfg.HspStrDte\u003e=\"20220701\" \u0026\u0026 !string.IsNullOrEmpty(WrkInfo.DepMst[\"DepOfcNam\"])) ? WrkInfo.DepMst[\"DepOfcNam\"] : WrkInfo.DepMst[\"DepKorNam\"]; var dr = GetDepMstDR(GetPatientInfo[\"AdpDepCod\"], ComInf.ComAcpDtm.Substring(0,8)); if(dr==null) return \"\"; return (HspCfg.HspStrDte\u003e=\"20220701\" \u0026\u0026 !string.IsNullOrEmpty(dr[\"DepOfcNam\"])) ? dr[\"DepOfcNam\"].Trim() : dr[\"DepKorNam\"].Trim();",
"evidence": "5697-5732 (UidDtrYon 5701, HspStrDte 비교 5702/5715, AdpDepCod 5711, GetDepMstDR 5712)",
"confidence": "high",
"blocker": "clsCommonLib.GetDepMstDR(psDepCod, psStrDte) — clsCommonLib.vb 13117. 로그인 사용자 분기는 moWrkInfoBiz.UidMst/DepMst(환자 문맥 밖) 가 필요하고, clsCommonLib.HspCfg.HspStrDte 도 필요하다.",
"note": "AdpDepCod 는 GetPatientInfo 조인의 별칭이지만 SQL 에서 `CodDepCod AdpDepCod` 로 확정된다(dtCommonLib.vb 3699) — 즉 row=CodInf, column=CodDepCod. HspStrDte\u003e=\"20220701\" 는 날짜가 아니라 문자열 비교다(레거시 그대로).",
"action": "UidDtrYon=\u0027Y\u0027 면 로그인자 DepMst, 아니면 환자 AdpDepCod 의 DepMst(ComAcpDtm 앞8자 기준)를 읽고, HspStrDte\u003e=\u002720220701\u0027 이고 DepOfcNam 이 있으면 DepOfcNam, 아니면 DepKorNam."
},
{
"tag": "OCM_User진료과_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "if (WrkInfo.UidMst[\"UidDtrYon\"].Trim()==\"Y\") return (HspCfg.HspStrDte\u003e=\"20220701\" \u0026\u0026 !string.IsNullOrEmpty(WrkInfo.DepMst[\"DepOfcNam\"])) ? WrkInfo.DepMst[\"DepOfcNam\"] : WrkInfo.DepMst[\"DepKorNam\"]; var dr = GetDepMstDR(GetPatientInfo[\"AdpDepCod\"], ComInf.ComAcpDtm.Substring(0,8)); if(dr==null) return \"\"; return (HspCfg.HspStrDte\u003e=\"20220701\" \u0026\u0026 !string.IsNullOrEmpty(dr[\"DepOfcNam\"])) ? dr[\"DepOfcNam\"].Trim() : dr[\"DepKorNam\"].Trim();",
"evidence": "5762-5797 (5776 AdpDepCod, 5777 GetDepMstDR 에 비-Refer ComInfDR 사용)",
"confidence": "high",
"blocker": "clsCommonLib.GetDepMstDR(psDepCod, psStrDte) — clsCommonLib.vb 13117.",
"note": "이름에 _Refer 가 붙었지만 본문은 OCM_User진료과(5697) 와 \u003c완전히 동일\u003e하다 — GetPatientInfo·ComInfDR 모두 _Refer 가 아닌 현재 문맥을 쓴다. 레거시 버그로 보이나 그대로 적는다.",
"action": "OCM_User진료과와 같은 본문을 넣는다 — 이름은 _Refer 지만 레거시가 참조 내원행을 쓰지 않는다는 점을 주석에 남긴다."
},
{
"tag": "OCM_User진료과_한글명칭",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "if (WrkInfo.UidMst[\"UidDtrYon\"].Trim()==\"Y\") return WrkInfo.DepMst[\"DepKorNam\"]; return GetDepMstDR(GetPatientInfo[\"AdpDepCod\"], ComInf.ComAcpDtm.Substring(0,8))[\"DepKorNam\"].Trim();",
"evidence": "5734-5760 (UidDtrYon 5738, DepKorNam 5740/5749, AdpDepCod 5746, GetDepMstDR 5747)",
"confidence": "high",
"blocker": "clsCommonLib.GetDepMstDR(psDepCod, psStrDte) — clsCommonLib.vb 13117.",
"note": "OCM_User진료과 와 달리 DepOfcNam·HspStrDte 분기가 없고 항상 DepKorNam 이다. 또 oDR null 검사도 없어 조회 실패 시 예외가 나고 Catch 가 \"\" 를 돌려준다(5753-5757).",
"action": "OCM_User진료과와 같은 경로로 읽되 \u003cb\u003e항상 DepKorNam\u003c/b\u003e 을 돌려주는 매핑을 넣는다(DepOfcNam 분기 없음)."
},
{
"tag": "OCM_User진료의사명",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "if (WrkInfo.UidMst[\"UidDtrYon\"].Trim()==\"Y\") return WrkInfo.UidMst[\"UidNam\"]; var dr = GetUidMst(GetPatientInfo[\"AdpDtrCod\"], ComInf.ComAcpDtm.Substring(0,8)); return dr?[\"UidNam\"].Trim() ?? \"\";",
"evidence": "5799-5827 (UidDtrYon 5805, UidNam 5806/5814, AdpDtrCod 5810, GetUidMst 5811)",
"confidence": "high",
"blocker": "clsCommonLib.GetUidMst(psUidCod, psStrDte) — clsCommonLib.vb 10609.",
"note": "AdpDtrCod 는 조인 별칭이지만 SQL 에서 `CodDtrCod AdpDtrCod` 로 확정된다(dtCommonLib.vb 3699) — row=CodInf, column=CodDtrCod. 로그인 사용자가 의사면 환자 문맥을 아예 보지 않고 로그인 사용자 이름을 돌려준다.",
"action": "UidDtrYon=\u0027Y\u0027 면 로그인자 UidNam(무가공), 아니면 환자 AdpDtrCod 로 M_UidMst(ComAcpDtm 앞8자)를 찾아 UidNam.Trim."
},
{
"tag": "OCM_User진료의사명_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "if (WrkInfo.UidMst[\"UidDtrYon\"].Trim()==\"Y\") return WrkInfo.UidMst[\"UidNam\"]; var dr = GetUidMst(CodInf[\"CodDtrCod\"], ComInf.ComAcpDtm.Substring(0,8)); return dr?[\"UidNam\"].Trim() ?? \"\";",
"evidence": "5829-5857 (UidDtrYon 5835, UidNam 5836/5844, CodInfDR(\"CodDtrCod\") 5840, GetUidMst 5841)",
"confidence": "high",
"blocker": "clsCommonLib.GetUidMst(psUidCod, psStrDte) — clsCommonLib.vb 10609.",
"note": "비-Refer 판(5799) 과 두 곳이 다르다: 의사코드를 GetPatientInfo[\"AdpDtrCod\"] 가 아니라 moPatInfoBiz.CodInfDR(\"CodDtrCod\") 에서 꺼낸다(값 자체는 같은 CodInf.CodDtrCod). 그리고 이름의 _Refer 와 달리 ComInfDR·CodInfDR 모두 \u003c현재\u003e 문맥이다 — _Refer 문맥을 전혀 쓰지 않는다.",
"action": "OCM_User진료의사명과 같되 의사코드를 환자 AdpDtrCod 가 아니라 CodInf 행의 CodDtrCod 에서 가져오게 한다."
},
{
"tag": "OCM_내원유형",
"kind": "computed",
"row": "ComInf",
"column": "ComPatTyp, ComEmgYon",
"formula": "var typ = ComInf.ComPatTyp; if (typ==\"I\") return \"입원\"; if (typ==\"O\") return ComInf.ComEmgYon==\"Y\" ? \"응급실\" : \"외래\"; return \"\";",
"evidence": "2896-2917 (PatTyp 2900, \"I\"→입원 2902-2904, \"O\" 2906, ComEmgYon 2909-2913)",
"confidence": "high",
"blocker": "",
"note": "moPatInfoBiz.PatTyp 는 msPatTyp = GetPatientInfo[\"ComPatTyp\"] 다(bzPatientInfo.vb 639, 1008-1012) → row=ComInf, column=ComPatTyp. ComEmgYon 은 GetPatientInfo 조인의 P_ComInf 몫이다(dtCommonLib.vb 3699 의 DA.COL(\"P_ComInf\",\"CM\"), 조인 대상 중 Com 접두는 P_ComInf 뿐; dtCommonLib.vb 9196/9206 에서 P_ComInf 컬럼임이 다시 확인됨). ComPatTyp 가 I·O 둘 다 아니면 빈 문자열이다.",
"action": "PatTyp=\u0027I\u0027 면 \"입원\", \u0027O\u0027 면 ComEmgYon=\u0027Y\u0027 일 때 \"응급실\" 아니면 \"외래\", 그 외 빈 값 — Computed 에 세 줄로 넣는다."
},
{
"tag": "OCM_담당의사_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp_Refer==\"I\" ? (ComInf_Refer.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum_Refer, SystemDateTime, 1)) : GetCodInfDT(ComNum_Refer, ComInf_Refer.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp_Refer==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf_Refer.ComAcpDtm.Substring(0,8)); return uid?[\"UidNam\"].Trim() ?? \"\";",
"evidence": "4069-4092 (분기 4074, GetCodInfDT 4076/4078/4082, GetUidMst 4080/4083, UidNam 4087)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "OCM_STFDTR 와 달리 codDT.Rows.Count 검사가 없어 조회 결과가 없으면 Rows(0) 에서 예외가 난다(4080/4083). 이 함수는 _Refer 문맥(ComNum_Refer·ComInfDR_Refer·PatTyp_Refer) 을 실제로 쓴다.",
"action": "참조 내원(ComNum_Refer/ComInfDR_Refer)으로 PatientContextStore.Doctor 를 한 번 더 호출해 UidNam 을 돌려준다 — 현재 내원용 doctor 사전을 재사용하면 안 된다."
},
{
"tag": "OCM_담당의사_영문",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp==\"I\" ? (ComInf.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?[\"UidEngNam\"].Trim() ?? \"\";",
"evidence": "4094-4117 (분기 4099, GetCodInfDT 4101/4103/4107, GetUidMst 4105/4108, UidEngNam 4112)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "OCM_담당의사_Refer 와 뼈대가 같고 최종 컬럼만 UidEngNam 이다. Rows.Count 검사 없음.",
"action": "FromDoctor 에 한 줄 추가 — Field(\"UidEngNam\") 을 돌려준다."
},
{
"tag": "OCM_담당의사_영문_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp_Refer==\"I\" ? (ComInf_Refer.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum_Refer, SystemDateTime, 1)) : GetCodInfDT(ComNum_Refer, ComInf_Refer.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp_Refer==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf_Refer.ComAcpDtm.Substring(0,8)); return uid?[\"UidEngNam\"].Trim() ?? \"\";",
"evidence": "4119-4142 (분기 4124, GetCodInfDT 4126/4128/4132, GetUidMst 4130/4133, UidEngNam 4137)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "_Refer 문맥을 실제로 사용한다. Rows.Count 검사 없음.",
"action": "참조 내원용 doctor 사전을 만든 뒤 UidEngNam 을 돌려준다."
},
{
"tag": "OCM_담당의사면허번호",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp==\"I\" ? (ComInf.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?[\"UidLicNum\"].Trim() ?? \"\";",
"evidence": "4144-4167 (분기 4149, GetCodInfDT 4151/4153/4157, GetUidMst 4155/4158, UidLicNum 4162)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "최종 컬럼만 UidLicNum 으로 다르다. Rows.Count 검사 없음.",
"action": "FromDoctor 에 한 줄 추가 — Field(\"UidLicNum\")."
},
{
"tag": "OCM_담당의사면허번호_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp_Refer==\"I\" ? (ComInf_Refer.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum_Refer, SystemDateTime, 1)) : GetCodInfDT(ComNum_Refer, ComInf_Refer.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp_Refer==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf_Refer.ComAcpDtm.Substring(0,8)); return uid?[\"UidLicNum\"].Trim() ?? \"\";",
"evidence": "4169-4192 (분기 4174, GetCodInfDT 4176/4178/4182, GetUidMst 4180/4183, UidLicNum 4187)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "_Refer 문맥을 실제로 사용한다. Rows.Count 검사 없음.",
"action": "참조 내원용 doctor 사전에서 UidLicNum 을 돌려준다."
},
{
"tag": "OCM_담당의사싸인",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp==\"I\" ? (ComInf.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); if(uid==null || string.IsNullOrEmpty(uid[\"UidImgPth\"])) return \"\"; var local = downloadFolder + Path.GetFileName(uid[\"UidImgPth\"]); FileTransfer.DownLoadFile(1, uid[\"UidImgPth\"], local); return local;",
"evidence": "4244-4309 (다운로드 폴더 4251, GetCodInfDT 4262/4264/4268, GetUidMst 4266/4269, UidImgPth 4274/4281/4282, DownLoadFile 4288, Return sLocalPath 4300)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT · clsCommonLib.GetUidMst · clsCommonLib.GetInstallPathFromServerInfoFile · clsCommonLib.CreateDirectory · M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 값이 아니라 \u003c서버에서 내려받은 파일 경로\u003e를 돌려준다.",
"note": "레거시가 이상하다: 0..4 로 5회 반복 루프를 만들어 놓고 본문 끝에서 sRemotePath 가 비지 않으면 무조건 Exit For 하므로 \u003c항상 1회\u003e만 돈다(4276-4298). sRemotePath 는 바로 위에서 UidImgPth 가 비지 않음을 확인한 값이라 조건은 사실상 항상 참이다. \"|\" 를 붙였다가 다시 잘라내는 것도 무의미한 왕복이다(4281-4285). 실패 시 MsgBox 로 대화상자를 띄운다(4289).",
"action": "담당의 행의 UidImgPth 로 파일서버에서 내려받아 로컬 경로를 돌려주는 경로를 만든다 — 값 생성은 순증이다."
},
{
"tag": "OCM_담당의사싸인_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp_Refer==\"I\" ? (ComInf_Refer.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum_Refer, SystemDateTime, 1)) : GetCodInfDT(ComNum_Refer, ComInf_Refer.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp_Refer==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf_Refer.ComAcpDtm.Substring(0,8)); if(uid==null || string.IsNullOrEmpty(uid[\"UidImgPth\"])) return \"\"; var local = downloadFolder + Path.GetFileName(uid[\"UidImgPth\"]); FileTransfer.DownLoadFile(1, uid[\"UidImgPth\"], local); return local;",
"evidence": "4311-4360대 (다운로드 폴더 4319, GetCodInfDT 4330/4332/4336, GetUidMst 4334/4337, UidImgPth 4342/4350)",
"confidence": "medium",
"blocker": "clsCommonLib.GetCodInfDT · clsCommonLib.GetUidMst · clsCommonLib.GetInstallPathFromServerInfoFile · clsCommonLib.CreateDirectory · M.EMR.FileTransfer.clsFileTransfer.DownLoadFile",
"note": "4350 이후 다운로드 루프 꼬리를 직접 읽지 못했다(4244 판과 4311 판이 4340 까지 완전히 동형이라 나머지도 같다고 보았다) — 그래서 confidence 를 medium 으로 둔다. 앞부분은 _Refer 문맥을 실제로 사용한다.",
"action": "참조 내원 담당의로 같은 다운로드 경로를 만든다."
},
{
"tag": "OCM_담당의사전문의번호",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp==\"I\" ? (ComInf.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?[\"UidSpcLic\"].Trim() ?? \"\";",
"evidence": "4194-4217 (분기 4199, GetCodInfDT 4201/4203/4207, GetUidMst 4205/4208, UidSpcLic 4212)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "최종 컬럼만 UidSpcLic 으로 다르다. Rows.Count 검사 없음.",
"action": "FromDoctor 에 전문의번호 분기를 추가한다 — doctor 사전은 M_UidMst 한 행이므로(PatientContextStore.cs:166) 그 행에 컬럼이 있는지부터 확인한다."
},
{
"tag": "OCM_담당의사전문의번호_Refer",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var codDT = PatTyp_Refer==\"I\" ? (ComInf_Refer.ComPrgStt==\"ILV\" ? GetCodInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum_Refer, SystemDateTime, 1)) : GetCodInfDT(ComNum_Refer, ComInf_Refer.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0][\"CodDtrCod\"], PatTyp_Refer==\"I\" ? codDT.Rows[0][\"CodEndDtm\"].Substring(0,8) : ComInf_Refer.ComAcpDtm.Substring(0,8)); return uid?[\"UidSpcLic\"].Trim() ?? \"\";",
"evidence": "4219-4242 (분기 4224, GetCodInfDT 4226/4228/4232, GetUidMst 4230/4233, UidSpcLic 4237)",
"confidence": "high",
"blocker": "clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime",
"note": "_Refer 문맥을 실제로 사용한다. Rows.Count 검사 없음.",
"action": "PatientTagResolver.FromDoctor 의 태그 화이트리스트(:278)에 참조 내원 담당의 행을 하나 더 받는 분기를 추가하고 전문의번호 컬럼을 돌려준다."
},
{
"tag": "OCM_로그인_사용자싸인",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var pth = WrkInfo.UidMst[\"UidImgPth\"]; if(string.IsNullOrEmpty(pth)) return \"\"; var local = GetInstallPathFromServerInfoFile()+\"\\\\Tmp\\\\DownLoadedFiles\\\\Signature\\\\\" + pth.Split(\u0027\\\\\u0027).Last(); FileTransfer.DownLoadFile(1, pth, local); return local;",
"evidence": "4847-4902 (다운로드 폴더 4855, UidImgPth 4862/4869/4870, DownLoadFile 4876, Return sLocalPath 4888, Catch→MessageBox 4894-4897)",
"confidence": "high",
"blocker": "clsCommonLib.GetInstallPathFromServerInfoFile · clsCommonLib.CreateDirectory · M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 서버 파일을 내려받아 로컬 경로를 돌려준다.",
"note": "환자 문맥을 전혀 쓰지 않는다 — 값의 출처는 로그인 사용자 moWrkInfoBiz.UidMst 의 UidImgPth 뿐이다(그 자체는 context-scalar 성격). 담당의사싸인과 같은 \u0027항상 1회 도는 5회 루프\u0027 가 그대로 있다(4864-4886). 오류는 Catch 에서 MessageBox 후 \"\" 반환(4894-4897) — 미리보기에서는 사유 반환으로 바꾸면 된다.",
"action": "KindOf 의 \u0027싸인\u0027 → Image 분류를 우회하는 needs-lookup 경로를 만들고 M_UidMst.UidImgPth 를 파일서버에서 내려받아 로컬 경로를 돌려준다 — 분류를 그대로 두면 영원히 사유 문구만 나온다."
},
{
"tag": "OCM_병동",
"kind": "needs-lookup",
"row": "CowInf",
"column": "CowWadCod",
"formula": "if (PatTyp==\"I\") { adp = (ComInf.ComPrgStt==\"ILV\") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; } else if (PatTyp.ToUpper()==\"O\" \u0026\u0026 ComDayCar.ToUpper()==\"Y\") { adp = SystemDateTime; } else return \"\"; dt = GetCowInfDT(ComNum, adp); return dt.Rows.Count\u003e0 ? dt.Rows[0][\"CowWadCod\"].ToString() : \"\";",
"evidence": "6718-6748 (본문), dtCommonLib.vb 2979-3050 (GetCowInfDT 실체)",
"confidence": "medium",
"blocker": "clsCommonLib.GetInstance.GetCowInfDT(ComNum, AdpDtm) — 병상이력 P_CowInf 를 시점으로 다시 조회한다. 문맥이 든 CowInf 한 행이 아니라 이력 전체를 훑는다.",
"note": "GetCowInfDT 는 piJoin=0 으로 \u0027SELECT * FROM P_CowInf WHERE CowComNum=:ComNum AND :AdpDtm BETWEEN CowStrDtm AND CowEndDtm\u0027 를 실행한다(dtCommonLib.vb 2996-3047). ORDER BY 가 없는데 Rows(0) 를 집는다 — 구간이 겹치면 결과가 비결정적이다. ComPrgStt/ComLevDtm 는 ComInf 컬럼, ComDayCar 는 GetPatientInfo 경유(bzPatientInfo.vb 1066-1074)라 어느 표 컬럼인지 본문만으로는 확정 못 한다.",
"action": "Direct 에 (CowInf, CowWadCod) 를 넣되 레거시의 입원/낮병동 분기를 같이 옮긴다 — 외래이고 ComDayCar≠\u0027Y\u0027 면 빈 값이어야 한다."
},
{
"tag": "OCM_병동_Refer",
"kind": "needs-lookup",
"row": "CowInf",
"column": "CowWadCod",
"formula": "if (PatTyp_Refer==\"I\") { var cow = ComInf_Refer.ComPrgStt==\"ILV\" ? GetCowInfDT(ComNum_Refer, ComInf_Refer.ComLevDtm.Substring(0,12)) : GetCowInfDT(ComNum_Refer, SystemDateTime); return cow?.Rows.Count\u003e0 ? cow.Rows[0][\"CowWadCod\"].ToString() : \"\"; } if (PatTyp_Refer.ToUpper()==\"O\" \u0026\u0026 GetComInfDR(ComNum_Refer)[\"ComDayCar\"]==\"Y\") { var cow = GetCowInfDT(ComNum_Refer, SystemDateTime); return cow?.Rows.Count\u003e0 ? cow.Rows[0][\"CowWadCod\"].ToString() : \"\"; } return \"\";",
"evidence": "6760-6799 (입원분기 6767, GetCowInfDT 6770/6772/6781, CowWadCod 6776/6784, GetComInfDR(...)[\"ComDayCar\"] 6780)",
"confidence": "high",
"blocker": "clsCommonLib.GetCowInfDT(psComNum, psAdpDtm) — clsCommonLib.vb 10555 · clsCommonLib.GetComInfDR(piComNum) — clsCommonLib.vb 10484.",
"note": "비-Refer 판(6718) 과 달리 낮병동 판정을 문맥 속성이 아니라 GetComInfDR(ComNum_Refer) 재조회로 한다(6780) — 조회가 하나 더 늘어난다. 이 함수는 _Refer 문맥을 제대로 사용한다.",
"action": "참조 내원의 CowInf 행을 따로 읽어 CowWadCod 를 돌려주는 매핑을 추가한다 — 현행 CowInf 는 주 내원분 한 행뿐이다."
},
{
"tag": "OCM_병동_병실",
"kind": "needs-lookup",
"row": "CowInf",
"column": "CowWadCod, CowRomCod",
"formula": "if (PatTyp!=\"I\") return \"\"; var cow = ComInf.ComPrgStt==\"ILV\" ? GetCowInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12)) : GetCowInfDT(ComNum, SystemDateTime); if(cow==null||cow.Rows.Count==0) return \"\"; return cow.Rows[0][\"CowWadCod\"] + \" / \" + cow.Rows[0][\"CowRomCod\"];",
"evidence": "7000-7031 (입원분기 7007, ComPrgStt 7009, GetCowInfDT 7010/7012, 결합 7016)",
"confidence": "high",
"blocker": "clsCommonLib.GetCowInfDT(psComNum, psAdpDtm) — clsCommonLib.vb 10555.",
"note": "OCM_병동(6718) 에 있던 낮병동(외래+ComDayCar=Y) 분기가 여기에는 \u003c없다\u003e — 입원(PatTyp=\"I\") 이 아니면 무조건 빈 문자열이다. 구분자는 앞뒤 공백이 붙은 \" / \" 이고 코드값을 그대로 잇는다(이름 변환 없음).",
"action": "Computed 에 CowWadCod+CowRomCod 결합 규칙을 추가한다."
},
{
"tag": "OCM_병동_병실_베드",
"kind": "needs-lookup",
"row": "CowInf",
"column": "CowWadCod + CowRomCod + CowBedCod",
"formula": "if (PatTyp != \"I\") return \"\"; var dtm = (ComInf.ComPrgStt == \"ILV\") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; var cow = GetCowInfDT(ComNum, dtm); return cow.Rows.Count\u003e0 ? $\"{cow[0].CowWadCod} / {cow[0].CowRomCod} / {cow[0].CowBedCod}\" : \"\";",
"evidence": "bzDataInterface.vb 7033-7064 (본문), 핵심 7040·7042·7043·7045·7049",
"confidence": "medium",
"blocker": "clsCommonLib.GetCowInfDT(ComNum, AdpDtm) — 시점 필터가 붙은 P_CowInf 조회다. 부수적으로 clsCommonLib.SystemDateTime 도 필요.",
"note": "GetCowInfDT 는 clsCommonLib.vb 10555 → dtCommonLib.vb 2979(piJoin=0) 이고 실체는 `SELECT * FROM P_CowInf WHERE CowComNum=:ComNum AND :AdpDtm BETWEEN CowStrDtm AND CowEndDtm` 이다. 즉 컬럼 셋은 문맥의 CowInf 행과 같지만, 레거시는 \u003c그 시점에 유효한 행\u003e을 다시 골라 Rows(0) 를 쓴다. SheetMe 의 CowInf 단일 행이 이미 \u0027현재 유효한 행\u0027이라면 그대로 대체 가능 — 그 전제가 확인되지 않아 medium. 구분자는 공백 포함 \" / \" 다. 외래(PatTyp=\"O\")·그 외는 무조건 \"\" — 병실베드와 달리 낮병동 분기가 없다. 예외 시 레거시는 MessageBox 를 띄우고 \"\" 를 돌려준다.",
"action": "Computed 에 CowWadCod+CowRomCod+CowBedCod 결합 규칙을 추가한다 — 결합 구분자는 레거시 본문에서 확인해야 한다."
},
{
"tag": "OCM_병실베드",
"kind": "needs-lookup",
"row": "CowInf",
"column": "CowBedCod",
"formula": "if (PatTyp==\"I\") { adp = (ComInf.ComPrgStt==\"ILV\") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; dt=GetCowInfDT(ComNum,adp); return dt.Rows.Count\u003e0 ? dt.Rows[0][\"CowBedCod\"].ToString() : \"\"; } else if (PatTyp.ToUpper()==\"O\") { if (ComDayCar.ToUpper()==\"Y\") { dt=GetCowInfDT(ComNum,SystemDateTime); return dt.Rows.Count\u003e0 ? dt.Rows[0][\"CowBedCod\"].ToString() : \"\"; } return null; } else return \"\";",
"evidence": "6946-6989",
"confidence": "medium",
"blocker": "clsCommonLib.GetInstance.GetCowInfDT — OCM_병동 과 동일한 이력 조회.",
"note": "레거시 버그를 그대로 적는다: PatTyp=\"O\" 이면서 ComDayCar\u003c\u003e\"Y\" 인 경로에는 Return 이 없다(6969 If 가 6983 에서 Else 없이 닫힌다). VB 는 이때 String 기본값 Nothing 을 돌려준다 — \"\" 가 아니다. OCM_병동(6738)은 같은 조건을 ElseIf 한 줄로 합쳐 \"\" 를 주는데, 병실베드만 갈라져 있다.",
"action": "Direct 에 (CowInf, CowBedCod) 를 추가한다 — 병동과 같은 입원/낮병동 분기를 함께 옮긴다."
},
{
"tag": "OCM_부상병POA",
"kind": "needs-lookup",
"row": "",
"column": "OkdPoaTyp",
"formula": "dt = Okd_상병쿼리(ComNum, \"S\") 실행; foreach(row) sb.AppendLine(row[\"OkdPoaTyp\"].ToString().Trim()); return sb.ToString();",
"evidence": "9990-10029 (본문), 9999 (\"S\"), 10011 (OkdPoaTyp)",
"confidence": "low",
"blocker": "Okd_상병쿼리 + ExecuteQuery. 더 큰 문제: OkdPoaTyp 는 HspCod 분기 중 NYJB(14221)·Case Else(14342) SELECT 목록에만 있다. KIMEYE·SPHH·SYBS 분기의 SELECT 에는 OkdPoaTyp 가 없어서 Item(\"OkdPoaTyp\") 가 예외로 떨어진다.",
"note": "레거시가 이상해 보여도 그대로 적는다 — 병원 코드에 따라 이 태그는 항상 예외를 던지고 Catch 에서 MessageBox 후 \"\" 를 돌려준다(10024-10025). 옮길 때는 컬럼 부재를 사유로 돌려주는 게 맞다.",
"action": "부상병 행 조회를 추가하고 OkdPoaTyp 를 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_부상병POA_Refer",
"kind": "needs-lookup",
"row": "",
"column": "OkdPoaTyp",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"S\") 실행; foreach(row) sb.AppendLine(row[\"OkdPoaTyp\"].ToString().Trim()); return sb.ToString();",
"evidence": "10031-10070 (본문), 10037 (ComNum_Refer), 10040 (\"S\"), 10052 (OkdPoaTyp)",
"confidence": "low",
"blocker": "Okd_상병쿼리 + ExecuteQuery. OkdPoaTyp 컬럼이 일부 HspCod 분기 SELECT 에 없다(OCM_부상병POA 와 동일). ComNum_Refer 문맥도 미확인.",
"note": "OCM_부상병POA 와 ComNum 만 다르다.",
"action": "참조 내원의 부상병 행(OkdPoaTyp)을 읽는 신규 조회를 PatientContextStore 에 추가한다 — 현행에 상병 계열 조회 자체가 없다."
},
{
"tag": "OCM_부상병명_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdKorNam",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"S\") 실행; foreach(row) sb.AppendLine(row[\"KcdKorNam\"].ToString().Trim()); return sb.ToString();",
"evidence": "9949-9988 (본문), 9955 (ComNum_Refer), 9958 (\"S\")",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 미확인.",
"note": "비-Refer 판 OCM_부상병명(9884-9947)에는 HspCod 분기가 있다 — SPHH 는 KcdKorNam 뒤에 컬럼 \"확의\" 를 붙이고(9909), JEGG 는 OkdCfmSus=\"배\" 일 때 \"(배제)\" 를 붙인다(9916-9918). Refer 판에는 그 분기가 없이 KcdKorNam 만 쓴다 — 레거시가 두 판을 다르게 유지하고 있다.",
"action": "참조 내원 부상병 목록을 읽어 KcdKorNam 을 돌려주는 조회·매핑을 추가한다."
},
{
"tag": "OCM_부상병코드",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum, \"S\") 실행; foreach(row) sb.AppendLine(row[\"KcdElcCod\"].ToString().Trim()); return sb.ToString();",
"evidence": "9801-9840 (본문), 9810 (psOkdTyp=\"S\"), 14360-14361 (S → OkdDspSeq\u003c\u003e\u00271\u0027)",
"confidence": "medium",
"blocker": "Okd_상병쿼리(piComNum, \"S\") + clsDAFactory.ExecuteQuery.",
"note": "\"S\"=부상병은 쿼리에 AND OkdDspSeq \u003c\u003e \u00271\u0027 을 덧붙인다(14361). 주상병은 \"M\"(OkdDspSeq=\u00271\u0027). 세로 나열(AppendLine)이라 후행 CRLF 있음.",
"action": "부상병 목록에서 KcdElcCod 를 돌려주는 조회·매핑을 추가한다."
},
{
"tag": "OCM_부상병코드_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"S\") 실행; foreach(row) sb.AppendLine(row[\"KcdElcCod\"].ToString().Trim()); return sb.ToString();",
"evidence": "9842-9881 (본문), 9848 (ComNum_Refer), 9851 (\"S\")",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 미확인.",
"note": "OCM_부상병코드 와 ComNum 만 다르다.",
"action": "참조 내원 부상병 목록에서 KcdElcCod 를 돌려주는 조회·매핑을 추가한다."
},
{
"tag": "OCM_상병명_가로",
"kind": "needs-lookup",
"row": "",
"column": "KcdKorNam + OkdCfmSus",
"formula": "dt = Okd_상병쿼리(ComNum, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdKorNam\"].ToString().Trim() + (r[\"OkdCfmSus\"].ToString().Trim()==\"확\" ? \"(확진)\" : r[\"OkdCfmSus\"].ToString().Trim()==\"의\" ? \"(의증)\" : \"\")));",
"evidence": "8170-8231 (본문), 14160-14162·14358-14366·14616 (Okd_상병쿼리)",
"confidence": "medium",
"blocker": "Okd_상병쿼리(piComNum) + clsDAFactory.ExecuteQuery — O_OkdInf / M_KcdMst 조인이라 다섯 문맥 행으로는 못 닿는다. TAG-REMAINING-360.md 가 CTX 로 분류했지만 실제로는 조회 태그다.",
"note": "Okd_상병쿼리 기본값 psOkdTyp=\"T\"(전체). 쿼리는 O_OkdInf INNER JOIN P_ComInf INNER JOIN M_KcdMst, OkdStt=\u0027E\u0027, ROW_NUMBER() PARTITION BY KcdCod ORDER BY KcdStrDte DESC 로 OKD_RN=1 만, 마지막 ORDER BY OkdDspSeq(14616). HspCod 별로 SQL 이 갈린다(KIMEYE/NYJB/SYBS/Case Else, 14168-14356) — 옮길 때 병원별 분기를 결정해야 한다. 구분자는 마지막 행만 빼고 \", \".",
"action": "상병 목록을 가로로 잇는 조회·조립을 추가한다(KcdKorNam + OkdCfmSus)."
},
{
"tag": "OCM_상병명_가로_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdKorNam + OkdCfmSus",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdKorNam\"].Trim() + (r[\"OkdCfmSus\"].Trim()==\"확\" ? \"(확진)\" : r[\"OkdCfmSus\"].Trim()==\"의\" ? \"(의증)\" : \"\")));",
"evidence": "8233-8291",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. 더해서 moPatInfoBiz.ComNum_Refer(참조 내원번호) 가 SheetMe 문맥에 있는지 미확인.",
"note": "OCM_상병명_가로 와 본문이 같고 ComNum 만 ComNum_Refer(8241). 다만 비-Refer 판(8198)에 있는 루프 선두의 sCfmSus=\"\" 초기화가 여기엔 없다 — If/ElseIf/Else 가 항상 대입하므로 동작 차이는 없다.",
"action": "참조 내원 상병 목록을 한 줄로 잇는 조회·조립을 추가한다 — KcdKorNam 에 OkdCfmSus(확진/의증)를 붙이는 규칙까지 같이 옮긴다."
},
{
"tag": "OCM_상병명_영어_가로",
"kind": "needs-lookup",
"row": "",
"column": "KcdEngNam",
"formula": "dt = Okd_상병쿼리(ComNum, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdEngNam\"].ToString().Trim()));",
"evidence": "8469-8515",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + clsDAFactory.ExecuteQuery — O_OkdInf/M_KcdMst 조회.",
"note": "확진·의증 표기 없음. 한글판(OCM_상병명_가로)과 달리 OkdCfmSus 를 보지 않는다.",
"action": "상병 목록의 KcdEngNam 을 가로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병명_영어_가로_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdEngNam",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdEngNam\"].ToString().Trim()));",
"evidence": "8517-8563",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 문맥 존재 여부 미확인.",
"note": "8524 만 ComNum_Refer 로 다르고 나머지는 OCM_상병명_영어_가로 와 동일하다.",
"action": "참조 내원 상병 목록의 KcdEngNam 을 가로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병명_영어_세로",
"kind": "needs-lookup",
"row": "",
"column": "KcdEngNam",
"formula": "dt = Okd_상병쿼리(ComNum, \"T\") 실행; foreach(row) sb.AppendLine(row[\"KcdEngNam\"].ToString().Trim()); return sb.ToString();",
"evidence": "8565-8604",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery.",
"note": "AppendLine 이라 마지막 행 뒤에도 CRLF 가 붙는다 — 가로판의 Join 과 달리 후행 개행이 남는다. 그대로 옮겨야 미리보기 줄수가 맞다.",
"action": "상병 목록의 KcdEngNam 을 세로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병명_영어_세로_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdEngNam",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"T\") 실행; foreach(row) sb.AppendLine(row[\"KcdEngNam\"].ToString().Trim()); return sb.ToString();",
"evidence": "8606-8645",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 미확인.",
"note": "8612 만 ComNum_Refer. 후행 CRLF 동일.",
"action": "참조 내원 상병 목록의 KcdEngNam 을 줄바꿈으로 잇는 조회·조립을 추가한다 — 가로/세로는 구분자만 다르므로 한 헬퍼로 묶는다."
},
{
"tag": "OCM_상병코드_가로",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdElcCod\"].ToString().Trim()));",
"evidence": "8647-8693",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery.",
"note": "KcdCod 가 아니라 KcdElcCod(전자청구용 코드)다. 쿼리는 KcdCod 와 KcdElcCod 를 둘 다 뽑는데(14200) 태그가 고르는 건 KcdElcCod.",
"action": "상병 목록의 KcdElcCod 를 가로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병코드_가로_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"T\") 실행; return string.Join(\", \", dt.Rows.Select(r =\u003e r[\"KcdElcCod\"].ToString().Trim()));",
"evidence": "8695-8741",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 미확인.",
"note": "8702 만 ComNum_Refer.",
"action": "참조 내원 상병 목록의 KcdElcCod 를 가로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병코드_세로",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum, \"T\") 실행; foreach(row) sb.AppendLine(row[\"KcdElcCod\"].ToString().Trim()); return sb.ToString();",
"evidence": "8743-8782",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery.",
"note": "AppendLine — 후행 CRLF 있음.",
"action": "상병 목록의 KcdElcCod 를 세로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_상병코드_세로_Refer",
"kind": "needs-lookup",
"row": "",
"column": "KcdElcCod",
"formula": "dt = Okd_상병쿼리(ComNum_Refer, \"T\") 실행; foreach(row) sb.AppendLine(row[\"KcdElcCod\"].ToString().Trim()); return sb.ToString();",
"evidence": "8784-8823",
"confidence": "medium",
"blocker": "Okd_상병쿼리 + ExecuteQuery. ComNum_Refer 미확인.",
"note": "8790 만 ComNum_Refer.",
"action": "참조 내원 상병 목록의 KcdElcCod 를 세로로 잇는 조회·조립을 추가한다."
},
{
"tag": "OCM_수술_OprPatETC",
"kind": "needs-lookup",
"row": "",
"column": "OprPatEtc",
"formula": "dt = GetOprInfDT(); return dt != null \u0026\u0026 dt.Rows.Count \u003e 0 ? dt.Rows[0][\"OprPatEtc\"].ToString() : \"\";",
"evidence": "13190-13201",
"confidence": "medium",
"blocker": "GetOprInfDT() (13203) — 자체 SQL 조회.",
"note": "태그 이름은 OprPatETC 인데 읽는 컬럼은 OprPatEtc 다(13196). Trim 없음.",
"action": "수술 정보(S_OprInf 계열) 조회를 새로 추가하고 OprPatEtc 를 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_수술부위",
"kind": "needs-lookup",
"row": "",
"column": "OprRegionName",
"formula": "dt = GetOprInfDT(); return dt != null \u0026\u0026 dt.Rows.Count \u003e 0 ? dt.Rows[0][\"OprRegionName\"].ToString() : \"\";",
"evidence": "13177-13188",
"confidence": "medium",
"blocker": "GetOprInfDT() (13203) — 자체 SQL 조회. OprRegionName 이 그 결과셋의 별칭인지 원컬럼인지는 13203 이하 쿼리를 읽어야 확정된다.",
"note": "Trim 없음 — 원문 그대로 돌려준다.",
"action": "수술 조회를 추가하고 OprRegionName 을 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_수술상병",
"kind": "needs-lookup",
"row": "",
"column": "OkdNam",
"formula": "dt = GetOprInfDT(); return dt != null \u0026\u0026 dt.Rows.Count \u003e 0 ? dt.Rows[0][\"OkdNam\"].ToString() : \"\";",
"evidence": "13135-13146",
"confidence": "medium",
"blocker": "GetOprInfDT() (같은 파일 13203) — 자체 SQL 조회 함수다. TAG-REMAINING-360.md 도 GetOprInfDT 를 SQL 갈래로 잡아 뒀다(줄 13203).",
"note": "레거시는 Trim 없이 `oDT.Rows(0).Item(\"OkdNam\") \u0026 \"\"` 로 DBNull 을 빈 문자열로 흘린다. GetOprInfDT 는 moPatInfoBiz.GetPatientInfo.Item(\"ComAcpDtm\").Substring(0,8) 을 조건으로 쓴다(13210).",
"action": "수술 조회를 추가하고 OkdNam 을 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_수술주상병",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var dt = GetOprInfDT_OKDMain(); return (dt != null \u0026\u0026 dt.Rows.Count \u003e 0) ? (dt.Rows[0][\"OkdNam\"] ?? \"\").ToString() : \"\";",
"evidence": "bzDataInterface.vb 13148-13162 (함수 본문), 13364-13372 (GetOprInfDT_OKDMain 진입부)",
"confidence": "high",
"blocker": "GetOprInfDT_OKDMain() — 자체 SQL(WITH OprInfRec ...)을 clsDAFactory.ExecuteQuery 로 돌린다. 바인드는 moPatInfoBiz.ComNum 과 GetPatientInfo.Item(\"ComAcpDtm\").Substring(0,8).",
"note": "문서가 CTX 로 분류했으나 실제로는 조회다. 본문 자체는 단순(첫 행의 OkdNam)이라 조회만 붙이면 그대로 옮겨진다. 형제 태그 OCM_수술상병(13135)은 같은 컬럼을 GetOprInfDT() 로 읽는다 — 주상병만 _OKDMain 판을 쓴다.",
"action": "수술 조회를 추가하고 수술 주상병 결정 규칙을 옮긴다 — 컬럼이 문서에 없어 레거시 본문 확인이 먼저다."
},
{
"tag": "OCM_심사_부상병명",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var dt = Query(Bkd_상병쿼리(ComNum, \"S\"), piComNum: ComNum); return dt.Rows.Count \u003e 0 ? string.Concat(dt.Rows.Select(r =\u003e r[\"KcdKorNam\"].ToString().Trim() + Environment.NewLine)) : \"\";",
"evidence": "bzDataInterface.vb 10326-10365 (본문), 14625-14694 (Bkd_상병쿼리, psOkdTyp=\"S\")",
"confidence": "high",
"blocker": "Bkd_상병쿼리(piComNum, \"S\") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회. 추가로 clsCommonLib.GetDtlCodVal(\"CHNGKCDCOD\",\"01\") 과 HspCfg.HspCod 분기(KIMEYE)도 탄다.",
"note": "문서가 CTX 로 분류했으나 실제로는 조회다. 주상병(첫 행만)과 달리 전 행을 AppendLine 으로 이어붙이므로 결과 끝에 개행이 하나 남는다.",
"action": "심사 상병 조회를 새로 추가하고 부상병명을 돌려주는 매핑을 넣는다 — 진료 상병(OKD)과 원천이 다른지 본문에서 확인한다."
},
{
"tag": "OCM_심사_부상병코드",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var dt = Query(Bkd_상병쿼리(ComNum, \"S\"), piComNum: ComNum); return dt.Rows.Count \u003e 0 ? string.Concat(dt.Rows.Select(r =\u003e r[\"KcdElcCod\"].ToString().Trim() + Environment.NewLine)) : \"\";",
"evidence": "bzDataInterface.vb 10285-10324 (본문), 14625-14694 (Bkd_상병쿼리, psOkdTyp=\"S\")",
"confidence": "high",
"blocker": "Bkd_상병쿼리(piComNum, \"S\") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.",
"note": "문서가 CTX 로 분류했으나 실제로는 조회다. 돌려주는 코드는 OkdKcdCod 가 아니라 M_KcdMst 의 KcdElcCod(전자청구 코드)다. 전 행을 개행으로 이어붙인다.",
"action": "심사 상병 조회를 추가하고 부상병코드를 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_심사_주상병명",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var dt = Query(Bkd_상병쿼리(ComNum, \"M\"), piComNum: ComNum); return dt.Rows.Count \u003e 0 ? dt.Rows[0][\"KcdKorNam\"].ToString() : \"\";",
"evidence": "bzDataInterface.vb 10253-10283 (본문), 14625-14694 (Bkd_상병쿼리, psOkdTyp=\"M\")",
"confidence": "high",
"blocker": "Bkd_상병쿼리(piComNum, \"M\") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.",
"note": "문서가 CTX 로 분류했으나 실제로는 조회다. 부상병 판과 달리 Trim 없이 첫 행만 돌려준다.",
"action": "심사 상병 조회를 추가하고 주상병명을 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_심사_주상병코드",
"kind": "needs-lookup",
"row": "",
"column": "",
"formula": "var dt = Query(Bkd_상병쿼리(ComNum, \"M\"), piComNum: ComNum); return dt.Rows.Count \u003e 0 ? dt.Rows[0][\"KcdElcCod\"].ToString() : \"\";",
"evidence": "bzDataInterface.vb 10220-10250 (본문), 14625-14694 (Bkd_상병쿼리, psOkdTyp=\"M\")",
"confidence": "high",
"blocker": "Bkd_상병쿼리(piComNum, \"M\") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.",
"note": "문서가 CTX 로 분류했으나 실제로는 조회다. OkdKcdCod 가 아니라 KcdElcCod 를 돌려준다.",
"action": "심사 상병 조회를 추가하고 주상병코드를 돌려주는 매핑을 넣는다."
},
{
"tag": "OCM_외래내원일시_Refer",
"kind": "formatted",
"row": "ComInf",
"column": "ComAcpDtm",
"formula": "Data2Format(ComInf_Refer.ComAcpDtm.Substring(0,12), \"9999-99-99 99:99\") // 참조 내원(ComNum_Refer)의 P_ComInf 행",
"evidence": "bzDataInterface.vb 1074-1078 (본문), 1080-1084 (Private 외래내원일시), bzPatientInfo.vb 800-802 (ComInfDR_Refer → PatientInfoBiz_Refer.GetComInfDR), 784-790 (GetComInfDR = P_ComInf 행)",
"confidence": "medium",
"blocker": "ComInfDR_Refer 는 SetPatientInfo_Refer(piComNum) 로 별도 주입되는 \u003c두 번째 내원 문맥\u003e의 P_ComInf 행이다(bzPatientInfo.vb 329-336, 758-760). SheetMe 에 참조 내원 ComNum 을 넣을 통로가 아직 없다.",
"note": "컬럼·계산 자체는 비-Refer 판과 완전히 동일하고 문맥만 다르다. PatientInfoBiz_Refer 는 미설정 시 빈 bzPatientInfo 를 지연 생성하므로 GetPatientInfo 가 Nothing 이 되고, 이 함수는 Try/Catch 가 없어 NullReference 를 그대로 던진다.",
"action": "참조 내원의 ComInf 를 읽는 경로를 만들고 ComAcpDtm 을 Stamp 로 찍는다 — 기존 Stamp(:112-115)를 그대로 쓸 수 있다."
}
]
+124
View File
@@ -0,0 +1,124 @@
[needs-lookup] ETC_로그인사용자_서명
formula: path = clsCommonLib.GetUserInfo().UidImgPth; if (path == "") return ""; local = InstallPath + "\\Tmp\\DownLoadedFiles\\Signature\\" + path.Split('\\').Last(); FileTransfer.DownLoadFile(1, path, local); return local;
blocker: clsCommonLib.GetInstance.GetUserInfo (UidImgPth), clsCommonLib.GetInstance.GetInstallPathFromServerInfoFile, clsCommonLib.GetInstance.CreateDirectory, M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 파일서버 다운로드가 필요해 지금은 못 옮긴다
[needs-lookup] OCM_OPNAME
formula: dt = GetOprInfDT(); return dt?.Rows.Count > 0 ? dt.Rows[0]["OPNAME"].ToString().Trim() : null;
blocker: GetOprInfDT (bzDataInterface.vb:13203) — 수술 정보 SQL 을 먼저 옮겨야 한다
[needs-lookup] OCM_STFDTR
formula: var codDT = PatTyp=="I" ? (ComInf.ComPrgStt=="ILV" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); if(codDT.Rows.Count==0) return ""; var stf = codDT.Rows[0]["CodStfDtr"]; if(string.IsNullOrEmpty(stf)) return ""; var uid = GetUidMst(stf, PatTyp=="I" ? codDT.Rows[0]["CodEndDtm"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?["UidNam"].Trim() ?? "";
blocker: clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) — 둘 다 문맥의 다섯 행이 아니라 별도 질의다(clsCommonLib.vb 10510 · 10609). clsCommonLib.SystemDateTime 도 필요.
[needs-lookup] OCM_User진료과
formula: if (WrkInfo.UidMst["UidDtrYon"].Trim()=="Y") return (HspCfg.HspStrDte>="20220701" && !string.IsNullOrEmpty(WrkInfo.DepMst["DepOfcNam"])) ? WrkInfo.DepMst["DepOfcNam"] : WrkInfo.DepMst["DepKorNam"]; var dr = GetDepMstDR(GetPatientInfo["AdpDepCod"], ComInf.ComAcpDtm.Substring(0,8)); if(dr==null) return ""; return (HspCfg.HspStrDte>="20220701" && !string.IsNullOrEmpty(dr["DepOfcNam"])) ? dr["DepOfcNam"].Trim() : dr["DepKorNam"].Trim();
blocker: clsCommonLib.GetDepMstDR(psDepCod, psStrDte) — clsCommonLib.vb 13117. 로그인 사용자 분기는 moWrkInfoBiz.UidMst/DepMst(환자 문맥 밖) 가 필요하고, clsCommonLib.HspCfg.HspStrDte 도 필요하다.
[needs-lookup] OCM_User진료과_한글명칭
formula: if (WrkInfo.UidMst["UidDtrYon"].Trim()=="Y") return WrkInfo.DepMst["DepKorNam"]; return GetDepMstDR(GetPatientInfo["AdpDepCod"], ComInf.ComAcpDtm.Substring(0,8))["DepKorNam"].Trim();
blocker: clsCommonLib.GetDepMstDR(psDepCod, psStrDte) — clsCommonLib.vb 13117.
[needs-lookup] OCM_User진료의사명
formula: if (WrkInfo.UidMst["UidDtrYon"].Trim()=="Y") return WrkInfo.UidMst["UidNam"]; var dr = GetUidMst(GetPatientInfo["AdpDtrCod"], ComInf.ComAcpDtm.Substring(0,8)); return dr?["UidNam"].Trim() ?? "";
blocker: clsCommonLib.GetUidMst(psUidCod, psStrDte) — clsCommonLib.vb 10609.
[computed] OCM_내원유형
formula: var typ = ComInf.ComPatTyp; if (typ=="I") return "입원"; if (typ=="O") return ComInf.ComEmgYon=="Y" ? "응급실" : "외래"; return "";
blocker:
[needs-lookup] OCM_담당의사_영문
formula: var codDT = PatTyp=="I" ? (ComInf.ComPrgStt=="ILV" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0]["CodDtrCod"], PatTyp=="I" ? codDT.Rows[0]["CodEndDtm"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?["UidEngNam"].Trim() ?? "";
blocker: clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime
[needs-lookup] OCM_담당의사면허번호
formula: var codDT = PatTyp=="I" ? (ComInf.ComPrgStt=="ILV" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0]["CodDtrCod"], PatTyp=="I" ? codDT.Rows[0]["CodEndDtm"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?["UidLicNum"].Trim() ?? "";
blocker: clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime
[needs-lookup] OCM_담당의사싸인
formula: var codDT = PatTyp=="I" ? (ComInf.ComPrgStt=="ILV" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0]["CodDtrCod"], PatTyp=="I" ? codDT.Rows[0]["CodEndDtm"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); if(uid==null || string.IsNullOrEmpty(uid["UidImgPth"])) return ""; var local = downloadFolder + Path.GetFileName(uid["UidImgPth"]); FileTransfer.DownLoadFile(1, uid["UidImgPth"], local); return local;
blocker: clsCommonLib.GetCodInfDT · clsCommonLib.GetUidMst · clsCommonLib.GetInstallPathFromServerInfoFile · clsCommonLib.CreateDirectory · M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 값이 아니라 <서버에서 내려받은 파일 경로>를 돌려준다.
[needs-lookup] OCM_담당의사전문의번호
formula: var codDT = PatTyp=="I" ? (ComInf.ComPrgStt=="ILV" ? GetCodInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12), 1) : GetCodInfDT(ComNum, SystemDateTime, 1)) : GetCodInfDT(ComNum, ComInf.ComAcpDtm.Substring(0,12), 1); var uid = GetUidMst(codDT.Rows[0]["CodDtrCod"], PatTyp=="I" ? codDT.Rows[0]["CodEndDtm"].Substring(0,8) : ComInf.ComAcpDtm.Substring(0,8)); return uid?["UidSpcLic"].Trim() ?? "";
blocker: clsCommonLib.GetCodInfDT(psComNum, psAdpDtm, piJoin) · clsCommonLib.GetUidMst(psUidCod, psStrDte) · clsCommonLib.SystemDateTime
[needs-lookup] OCM_로그인_사용자싸인
formula: var pth = WrkInfo.UidMst["UidImgPth"]; if(string.IsNullOrEmpty(pth)) return ""; var local = GetInstallPathFromServerInfoFile()+"\\Tmp\\DownLoadedFiles\\Signature\\" + pth.Split('\\').Last(); FileTransfer.DownLoadFile(1, pth, local); return local;
blocker: clsCommonLib.GetInstallPathFromServerInfoFile · clsCommonLib.CreateDirectory · M.EMR.FileTransfer.clsFileTransfer.DownLoadFile — 서버 파일을 내려받아 로컬 경로를 돌려준다.
[needs-lookup] OCM_병동
formula: if (PatTyp=="I") { adp = (ComInf.ComPrgStt=="ILV") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; } else if (PatTyp.ToUpper()=="O" && ComDayCar.ToUpper()=="Y") { adp = SystemDateTime; } else return ""; dt = GetCowInfDT(ComNum, adp); return dt.Rows.Count>0 ? dt.Rows[0]["CowWadCod"].ToString() : "";
blocker: clsCommonLib.GetInstance.GetCowInfDT(ComNum, AdpDtm) — 병상이력 P_CowInf 를 시점으로 다시 조회한다. 문맥이 든 CowInf 한 행이 아니라 이력 전체를 훑는다.
[needs-lookup] OCM_병동_병실
formula: if (PatTyp!="I") return ""; var cow = ComInf.ComPrgStt=="ILV" ? GetCowInfDT(ComNum, ComInf.ComLevDtm.Substring(0,12)) : GetCowInfDT(ComNum, SystemDateTime); if(cow==null||cow.Rows.Count==0) return ""; return cow.Rows[0]["CowWadCod"] + " / " + cow.Rows[0]["CowRomCod"];
blocker: clsCommonLib.GetCowInfDT(psComNum, psAdpDtm) — clsCommonLib.vb 10555.
[needs-lookup] OCM_병동_병실_베드
formula: if (PatTyp != "I") return ""; var dtm = (ComInf.ComPrgStt == "ILV") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; var cow = GetCowInfDT(ComNum, dtm); return cow.Rows.Count>0 ? $"{cow[0].CowWadCod} / {cow[0].CowRomCod} / {cow[0].CowBedCod}" : "";
blocker: clsCommonLib.GetCowInfDT(ComNum, AdpDtm) — 시점 필터가 붙은 P_CowInf 조회다. 부수적으로 clsCommonLib.SystemDateTime 도 필요.
[needs-lookup] OCM_병실베드
formula: if (PatTyp=="I") { adp = (ComInf.ComPrgStt=="ILV") ? ComInf.ComLevDtm.Substring(0,12) : SystemDateTime; dt=GetCowInfDT(ComNum,adp); return dt.Rows.Count>0 ? dt.Rows[0]["CowBedCod"].ToString() : ""; } else if (PatTyp.ToUpper()=="O") { if (ComDayCar.ToUpper()=="Y") { dt=GetCowInfDT(ComNum,SystemDateTime); return dt.Rows.Count>0 ? dt.Rows[0]["CowBedCod"].ToString() : ""; } return null; } else return "";
blocker: clsCommonLib.GetInstance.GetCowInfDT — OCM_병동 과 동일한 이력 조회.
[needs-lookup] OCM_부상병POA
formula: dt = Okd_상병쿼리(ComNum, "S") 실행; foreach(row) sb.AppendLine(row["OkdPoaTyp"].ToString().Trim()); return sb.ToString();
blocker: Okd_상병쿼리 + ExecuteQuery. 더 큰 문제: OkdPoaTyp 는 HspCod 분기 중 NYJB(14221)·Case Else(14342) SELECT 목록에만 있다. KIMEYE·SPHH·SYBS 분기의 SELECT 에는 OkdPoaTyp 가 없어서 Item("OkdPoaTyp") 가 예외로 떨어진다.
[needs-lookup] OCM_부상병코드
formula: dt = Okd_상병쿼리(ComNum, "S") 실행; foreach(row) sb.AppendLine(row["KcdElcCod"].ToString().Trim()); return sb.ToString();
blocker: Okd_상병쿼리(piComNum, "S") + clsDAFactory.ExecuteQuery.
[needs-lookup] OCM_상병명_가로
formula: dt = Okd_상병쿼리(ComNum, "T") 실행; return string.Join(", ", dt.Rows.Select(r => r["KcdKorNam"].ToString().Trim() + (r["OkdCfmSus"].ToString().Trim()=="확" ? "(확진)" : r["OkdCfmSus"].ToString().Trim()=="의" ? "(의증)" : "")));
blocker: Okd_상병쿼리(piComNum) + clsDAFactory.ExecuteQuery — O_OkdInf / M_KcdMst 조인이라 다섯 문맥 행으로는 못 닿는다. TAG-REMAINING-360.md 가 CTX 로 분류했지만 실제로는 조회 태그다.
[needs-lookup] OCM_상병명_영어_가로
formula: dt = Okd_상병쿼리(ComNum, "T") 실행; return string.Join(", ", dt.Rows.Select(r => r["KcdEngNam"].ToString().Trim()));
blocker: Okd_상병쿼리 + clsDAFactory.ExecuteQuery — O_OkdInf/M_KcdMst 조회.
[needs-lookup] OCM_상병명_영어_세로
formula: dt = Okd_상병쿼리(ComNum, "T") 실행; foreach(row) sb.AppendLine(row["KcdEngNam"].ToString().Trim()); return sb.ToString();
blocker: Okd_상병쿼리 + ExecuteQuery.
[needs-lookup] OCM_상병코드_가로
formula: dt = Okd_상병쿼리(ComNum, "T") 실행; return string.Join(", ", dt.Rows.Select(r => r["KcdElcCod"].ToString().Trim()));
blocker: Okd_상병쿼리 + ExecuteQuery.
[needs-lookup] OCM_상병코드_세로
formula: dt = Okd_상병쿼리(ComNum, "T") 실행; foreach(row) sb.AppendLine(row["KcdElcCod"].ToString().Trim()); return sb.ToString();
blocker: Okd_상병쿼리 + ExecuteQuery.
[needs-lookup] OCM_수술_OprPatETC
formula: dt = GetOprInfDT(); return dt != null && dt.Rows.Count > 0 ? dt.Rows[0]["OprPatEtc"].ToString() : "";
blocker: GetOprInfDT() (13203) — 자체 SQL 조회.
[needs-lookup] OCM_수술부위
formula: dt = GetOprInfDT(); return dt != null && dt.Rows.Count > 0 ? dt.Rows[0]["OprRegionName"].ToString() : "";
blocker: GetOprInfDT() (13203) — 자체 SQL 조회. OprRegionName 이 그 결과셋의 별칭인지 원컬럼인지는 13203 이하 쿼리를 읽어야 확정된다.
[needs-lookup] OCM_수술상병
formula: dt = GetOprInfDT(); return dt != null && dt.Rows.Count > 0 ? dt.Rows[0]["OkdNam"].ToString() : "";
blocker: GetOprInfDT() (같은 파일 13203) — 자체 SQL 조회 함수다. TAG-REMAINING-360.md 도 GetOprInfDT 를 SQL 갈래로 잡아 뒀다(줄 13203).
[needs-lookup] OCM_수술주상병
formula: var dt = GetOprInfDT_OKDMain(); return (dt != null && dt.Rows.Count > 0) ? (dt.Rows[0]["OkdNam"] ?? "").ToString() : "";
blocker: GetOprInfDT_OKDMain() — 자체 SQL(WITH OprInfRec ...)을 clsDAFactory.ExecuteQuery 로 돌린다. 바인드는 moPatInfoBiz.ComNum 과 GetPatientInfo.Item("ComAcpDtm").Substring(0,8).
[needs-lookup] OCM_심사_부상병명
formula: var dt = Query(Bkd_상병쿼리(ComNum, "S"), piComNum: ComNum); return dt.Rows.Count > 0 ? string.Concat(dt.Rows.Select(r => r["KcdKorNam"].ToString().Trim() + Environment.NewLine)) : "";
blocker: Bkd_상병쿼리(piComNum, "S") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회. 추가로 clsCommonLib.GetDtlCodVal("CHNGKCDCOD","01") 과 HspCfg.HspCod 분기(KIMEYE)도 탄다.
[needs-lookup] OCM_심사_부상병코드
formula: var dt = Query(Bkd_상병쿼리(ComNum, "S"), piComNum: ComNum); return dt.Rows.Count > 0 ? string.Concat(dt.Rows.Select(r => r["KcdElcCod"].ToString().Trim() + Environment.NewLine)) : "";
blocker: Bkd_상병쿼리(piComNum, "S") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.
[needs-lookup] OCM_심사_주상병명
formula: var dt = Query(Bkd_상병쿼리(ComNum, "M"), piComNum: ComNum); return dt.Rows.Count > 0 ? dt.Rows[0]["KcdKorNam"].ToString() : "";
blocker: Bkd_상병쿼리(piComNum, "M") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.
[needs-lookup] OCM_심사_주상병코드
formula: var dt = Query(Bkd_상병쿼리(ComNum, "M"), piComNum: ComNum); return dt.Rows.Count > 0 ? dt.Rows[0]["KcdElcCod"].ToString() : "";
blocker: Bkd_상병쿼리(piComNum, "M") — B_OkdInf ⨝ P_ComInf ⨝ M_KcdMst 조회.
+327
View File
@@ -0,0 +1,327 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 여러 컨트롤을 함께 골랐을 때 <b>무엇을 한 번에 바꿔도 되는가</b> — 판정 한 곳.
///
/// <b>왜 순수 함수 한 곳인가.</b> 차단 근거의 대부분이 키 이름이 아니라 <b>대상 집합</b>에 달려 있다
/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가).
/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고,
/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고,
/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다.
///
/// <b>기준은 레거시가 열 수 있는 XML 이다.</b> 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을
/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라
/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다.
/// </summary>
public static class BatchEditPolicy
{
#region Member Fields
/// <summary>
/// 대상이 둘 이상이면 <b>언제나</b> 막는 키 — 값이 대상마다 달라야 의미가 있는 것들.
///
/// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다.
/// ① <b>유일해야 하는 식별자</b> — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상.
/// ② <b>남을 이름으로 가리키는 배선</b> — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면
/// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 <b>다른 곳</b>이 깨진다.
/// ③ <b>되돌릴 수 없는 것</b> — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
/// Undo 스냅샷으로 덮이지 않는 유일한 부류다.
/// </summary>
private static readonly HashSet<string> AlwaysBlocked = new(StringComparer.Ordinal)
{
// ① 유일해야 하는 것
"Name",
"SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다
"DisplaySequence",
"TabIndex",
"LinkToNewDesign",
"SMSWebControlName",
// 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다
"Location",
"Size",
"LocationOnBase",
// ② 남을 이름으로 가리키는 배선
"DataActionTagControl",
"GetDataActionTagControl",
"GetDataActionTagControlChange",
"AssociatedControl",
"Formula",
"FormulaResultCheck",
"EventHandlerMappingTag",
// ③ 되돌릴 수 없는 것 — 일련번호 발급
"SequenceCode",
"SequenceType",
"SequenceNumber",
"CheckSequenceAtSaving",
};
/// <summary>
/// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다.
///
/// <c>TextAlign</c> 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은
/// <c>ContentAlignment</c>(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는
/// <c>HorizontalAlignment</c>(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면
/// 텍스트박스 쪽에는 <b>레거시가 읽을 수 없는 값</b>이 들어간다.
/// </summary>
private static readonly HashSet<string> BlockedWhenTypesDiffer = new(StringComparer.Ordinal)
{
"TextAlign",
// 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) —
// 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다
"DataInterfaceTag",
};
/// <summary>
/// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 <b>속성과 크기를 함께</b> 고치므로 안전하다.
///
/// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 <c>LineGeometry</c> 를 태워
/// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어,
/// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다.
/// </summary>
private static readonly HashSet<string> BlockedInRawOnly = new(StringComparer.Ordinal)
{
"Orientation",
"BorderWidth",
};
/// <summary>
/// 타입마다 라벨이 갈리는 키의 <b>중립 라벨</b> — 섞였을 때 한쪽 타입의 말을 쓰지 않는다.
/// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다.
/// </summary>
private static readonly Dictionary<string, string> NeutralLabels = new(StringComparer.Ordinal)
{
["Checked"] = "기본값",
["Text"] = "텍스트",
["Items"] = "항목(줄바꿈 구분)",
};
#endregion
#region Types
/// <summary>일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유</summary>
public readonly record struct BatchVerdict(bool Allowed, string? Reason)
{
/// <summary>허용</summary>
public static BatchVerdict Ok => new(true, null);
/// <summary>차단 — 이유는 인스펙터에 그대로 보인다</summary>
public static BatchVerdict No(string reason) => new(false, reason);
}
#endregion
#region Methods
/// <summary>
/// 선택된 타입들이 <b>모두</b> 갖는 속성 — 이것이 "공통 속성"이다.
///
/// <b>키 이름만 같으면 안 된다.</b> 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다.
/// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다.
/// · <c>TextAlign</c> — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다).
/// · <c>BorderStyle</c> — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면
/// 키가 없는 대상이 <b>남의 기본값</b>으로 보이고, 갈렸는지 여부 판정부터 틀어진다.
/// · <c>visible</c>/<c>Visible</c> — 라벨만 소문자다(레거시가 그림자 속성을 쓴다).
/// 키 문자열이 다르므로 자연히 탈락한다. <b>별칭으로 묶지 않는다</b> — 묶으면 라벨에
/// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
///
/// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 <b>빈 집합</b>이다.
/// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다.
/// </summary>
public static IReadOnlyList<PropertyDef> MergeCommonDefs(IReadOnlyList<string> types)
{
if (types.Count == 0)
{
return Array.Empty<PropertyDef>();
}
var descriptors = new List<ControlDescriptor>(types.Count);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
return Array.Empty<PropertyDef>();
}
descriptors.Add(descriptor);
}
if (descriptors.Count == 1)
{
return descriptors[0].Properties;
}
var merged = new List<PropertyDef>();
// 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다
foreach (var def in descriptors[0].Properties)
{
var common = true;
var labelDiffers = false;
for (var i = 1; i < descriptors.Count && common; i++)
{
var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal));
if (other is null || other.Editor != def.Editor
|| !SameChoices(def.Choices, other.Choices)
|| !string.Equals(def.Default, other.Default, StringComparison.Ordinal))
{
common = false;
break;
}
labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal);
}
if (!common)
{
continue;
}
merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral)
? Relabel(def, neutral)
: def);
}
return merged;
}
/// <summary>
/// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다.
/// 조용히 빼면 사용자는 그 속성이 <b>존재하지 않는다</b>고 읽는다.
/// </summary>
public static int ExcludedCount(IReadOnlyList<string> types)
{
if (types.Count < 2)
{
return 0;
}
var all = new HashSet<string>(StringComparer.Ordinal);
foreach (var type in types)
{
if (ControlRegistry.Find(type) is not { } descriptor)
{
continue;
}
foreach (var def in descriptor.Properties)
{
all.Add(def.Key);
}
}
foreach (var def in MergeCommonDefs(types))
{
all.Remove(def.Key);
}
return all.Count;
}
/// <summary>
/// 이 키를 <b>이 대상들에</b> 한 번에 써도 되는가.
/// </summary>
/// <param name="key">PropBag 키</param>
/// <param name="targets">선택된 컨트롤 모델</param>
/// <param name="viaAdvancedRaw">고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다</param>
/// <param name="parentOf">컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다.</param>
public static BatchVerdict IsBatchable(string key, IReadOnlyList<ControlElement> targets,
bool viaAdvancedRaw = false, Func<ControlElement, ControlElement?>? parentOf = null)
{
// 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다
if (targets.Count <= 1)
{
return BatchVerdict.Ok;
}
if (AlwaysBlocked.Contains(key))
{
return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다");
}
if (viaAdvancedRaw && BlockedInRawOnly.Contains(key))
{
return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요");
}
var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count();
if (types > 1 && BlockedWhenTypesDiffer.Contains(key))
{
return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요");
}
// 라디오의 기본 선택 — 같은 부모에 둘 이상이면 <b>한 문항의 답이 둘</b>이 된다.
// 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을
// 부모에 붙이기 <b>전에</b> 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다.
// 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다.
if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf))
{
return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다");
}
return BatchVerdict.Ok;
}
/// <summary>
/// 대상 <b>전부</b>가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건.
///
/// 둘 다 필요하다.
/// · <b>전부 갖고 있어야</b> 한다. 없던 대상에 키가 생기면 레거시 서식생성기가
/// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다.
/// · <b>전부 문자열이어야</b> 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다.
/// <c>GetText</c> 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀
/// <b>갈렸다는 표시조차 나오지 않는다</b> — 경고 없이 base64 원문이 교체된다.
/// </summary>
public static bool AllShareTextShape(string key, IReadOnlyList<ControlElement> targets)
{
foreach (var target in targets)
{
if (!target.Props.Contains(key))
{
return false;
}
if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue))
{
return false;
}
}
return true;
}
/// <summary>이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용</summary>
public static int CountHaving(string key, IReadOnlyList<ControlElement> targets)
=> targets.Count(t => t.Props.Contains(key));
/// <summary>선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다.</summary>
private static bool SameChoices(string[]? left, string[]? right)
{
if (left is null || right is null)
{
return left is null && right is null;
}
return left.SequenceEqual(right, StringComparer.Ordinal);
}
/// <summary>
/// 라벨만 바꾼 복제 — <see cref="PropertyDef"/> 는 record 가 아니라 <c>with</c> 를 쓸 수 없다.
/// 선택지 배열은 <b>복사하지 않고 참조만</b> 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다).
/// </summary>
private static PropertyDef Relabel(PropertyDef def, string label) => new()
{
Key = def.Key,
Label = label,
Editor = def.Editor,
Choices = def.Choices,
Default = def.Default,
};
/// <summary>같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가</summary>
private static bool HasRadioSiblings(IReadOnlyList<ControlElement> targets,
Func<ControlElement, ControlElement?>? parentOf)
{
var seen = new List<(ControlElement? Parent, int Count)>();
foreach (var target in targets)
{
if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal))
{
continue;
}
// 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다.
// 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다.
var parent = parentOf?.Invoke(target);
var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent));
if (index < 0)
{
seen.Add((parent, 1));
continue;
}
if (seen[index].Count + 1 >= 2)
{
return true;
}
seen[index] = (parent, seen[index].Count + 1);
}
return false;
}
#endregion
}
+3 -1
View File
@@ -66,7 +66,9 @@ public static class ControlRegistry
/// </summary>
public static PropertyDef DataTableField => new()
{
Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField,
// 라벨에 '배선' 을 쓰지 않는다 — 전산실에서 배선은 랜선·전원이다.
// '값 가져오기' 는 방향이 이름에 들어 있어 형제 속성(데이터 태그)과도 구별된다.
Key = "DataTableField", Label = "값 가져오기", Editor = PropEditorKind.DataTableField,
};
}
@@ -0,0 +1,95 @@
using System.Data;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 조회 결과(컬럼·행 문자열)에서 <see cref="DataTableFieldSpec"/> 이 가리키는 한 칸을 꺼낸다 —
/// Rows 형은 행 번호로, Select 형은 <b>.NET DataTable.Select 를 그대로 호출</b>해서 고른다.
///
/// <b>필터 문법을 직접 파싱하지 않는다.</b> 레거시 배선의 Select 필터는 .NET
/// <c>DataTable.Select(filterExpression)</c> 문법 그대로다 — 같은 엔진에 넘기면
/// 문법 호환을 우리가 증명할 필요가 없다. 파서를 새로 쓰면 LIKE·괄호·이스케이프에서
/// 조용히 갈라진다.
///
/// <b>알려진 한계</b>: 우리 결과는 전부 문자열 컬럼이라(레거시는 Oracle 어댑터가
/// 실타입으로 채운다) 숫자 대소 비교 필터(예: <c>AMT &gt; 100</c>)는 사전순이 된다.
/// 운영 실측 필터는 대부분 동등 비교(<c>컬럼 = '값'</c>)라 실질 영향이 좁지만,
/// 대소 비교 필터를 만나면 값이 다를 수 있다 — 그때 타입 추론을 붙인다.
/// </summary>
public static class DataTableFieldPicker
{
#region Methods
/// <summary>한 칸의 값 — 못 고르면 사유</summary>
public static TagValue Pick(IReadOnlyList<string> columns, IReadOnlyList<string[]> rows,
DataTableFieldSpec spec)
{
var columnIndex = -1;
for (var i = 0; i < columns.Count; i++)
{
if (string.Equals(columns[i], spec.Field, StringComparison.OrdinalIgnoreCase))
{
columnIndex = i;
break;
}
}
if (columnIndex < 0)
{
return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"
+ $" (있는 컬럼: {string.Join(", ", columns.Take(8))})");
}
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
{
using var table = new DataTable();
foreach (var name in columns)
{
// SELECT * 조인은 컬럼명이 겹칠 수 있다 — Oracle 어댑터의 Fill 과 같은
// 규칙(이름1, 이름2 …)으로 비껴 담는다. spec.Field 는 첫 것과 매칭된다.
var unique = name;
var suffix = 1;
while (table.Columns.Contains(unique))
{
unique = name + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture);
suffix++;
}
table.Columns.Add(unique, typeof(string));
}
foreach (var row in rows)
{
table.Rows.Add(row.Select(object (c) => c ?? string.Empty).ToArray());
}
DataRow[] picked;
try
{
picked = table.Select(spec.Filter);
}
catch (Exception ex)
{
// 필터 문법이 깨졌다 — 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다
return new TagValue(false, $"Select 필터를 해석할 수 없습니다: {ex.Message}");
}
if (spec.RowIndex >= picked.Length)
{
return new TagValue(false, picked.Length == 0
? $"필터({spec.Filter})에 맞는 행이 없습니다"
: $"{spec.RowIndex}번 행이 없습니다(필터 결과 {picked.Length}행)");
}
var filtered = picked[spec.RowIndex][columnIndex]?.ToString() ?? string.Empty;
return filtered.Length > 0
? new TagValue(true, filtered)
: new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
}
if (spec.RowIndex >= rows.Count)
{
return new TagValue(false, rows.Count == 0
? "조회 결과가 0행입니다"
: $"{spec.RowIndex}번 행이 없습니다(결과 {rows.Count}행)");
}
var cell = rows[spec.RowIndex][columnIndex] ?? string.Empty;
return cell.Length > 0
? new TagValue(true, cell)
: new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
}
#endregion
}
@@ -0,0 +1,41 @@
namespace SheetMe.Core.Catalog;
/// <summary>태그 하나를 값으로 바꾼 결과</summary>
/// <param name="Resolved">값을 만들었는가 — false 면 <paramref name="Text"/> 는 사유다</param>
/// <param name="Text">값 또는 사유</param>
public readonly record struct TagValue(bool Resolved, string Text)
{
public static readonly TagValue None = new(false, string.Empty);
}
/// <summary>
/// 미리보기에서 데이터 태그를 값으로 바꾸는 주체.
///
/// <b>왜 인터페이스인가.</b> 지금 값을 만들 수 있는 것은 서버 시각·로그인 사용자 계열
/// 16종(운영 사용 579건, 9.1%)뿐이다. 나머지 368종은 환자·내원 문맥이 있어야 한다.
/// 그 문맥이 생기면 <b>같은 자리로</b> 더 강한 해석기가 들어와야 하므로,
/// 값이 흐를 관을 지금 파 두고 해석기만 갈아 끼운다.
///
/// <b>못 만든 것을 감추지 않는다.</b> 레거시는 값을 못 가져온 컨트롤을 노란색으로 칠했다
/// (<c>ucLoadSheetBase.vb:2300-2308</c>) — 그런 장치가 있다는 것 자체가 현장 실패가 흔하다는 방증이다.
/// 조용히 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다. 그래서 사유를 함께 돌려준다.
/// </summary>
public interface ITagValueResolver
{
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
TagValue Resolve(string tag);
}
/// <summary>
/// <c>DataTableField</c> 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
///
/// <b>태그와 다른 관이다.</b> 태그(<c>DataInterfaceTag</c>)는 함수 하나가 값을 만들지만,
/// 이쪽은 서식이 들고 있는 SQL 을 <b>실행</b>해서 나온 표에서 행·컬럼을 지목한다.
/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
/// </summary>
public interface IDataFieldResolver
{
/// <summary>이 배선의 값 — 못 만들면 사유가 담긴다</summary>
TagValue Resolve(DataTableFieldSpec spec);
}
@@ -427,6 +427,9 @@ public static class LegacyTagCatalog
"OCM_주상병명_Refer",
"OCM_주상병명_영어",
"OCM_주상병명_영어_Refer",
// 카탈로그 전수 추출(2026-07-16) 이후 레거시에 추가된 함수 —
// bzDataInterface.vb:10429 실존을 확인하고 등재했다(이름 게이트가 잡았다)
"OCM_주상병명_주상병코드",
"OCM_주상병일자",
"OCM_주상병일자_Refer",
"OCM_주상병코드",
+66
View File
@@ -0,0 +1,66 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// '수정'(제자리 정정)을 누가 쓸 수 있는가 — 레거시 <c>TK_MODIFY</c> 가시성 규칙을 옮긴 것.
///
/// <b>왜 저장과 다른가.</b> 수정은 활성 <c>SdgKey</c> 를 그대로 두고 그 행의 디자인만 바꾼다.
/// EMR 이 참조하던 키가 유지되므로 <b>되돌릴 표식이 남지 않는 쓰기</b>에 가깝고,
/// <c>E_SctMst</c> 를 건드리지 않아 새 컨트롤은 매핑 행이 생기지 않는다.
/// 레거시가 이 버튼만 전산실에 묶어 둔 이유가 그것이다
/// (<c>frmSheetDesigner.vb:1029-1060</c>, 주석 <c>'[추가] 박성한 20150302 전산실만 사용가능</c>).
/// 저장 버튼에는 이런 분기가 없다.
///
/// 규칙을 여기 순수 함수로 두는 이유: 병원 코드·부서 코드 조합이 세 갈래라 화면에서 즉석 판정하면
/// 어느 갈래가 왜 막혔는지 확인할 방법이 없다. 여기라면 표로 고정할 수 있다.
/// </summary>
public static class ModifyPolicy
{
#region Member Fields
/// <summary>이 두 병원은 전산실 외에 총무·구매 부서도 쓴다(레거시 :1033-1038)</summary>
private static readonly string[] WideHospitals = { "HANKOOK", "GNRH" };
private static readonly string[] WideDepartments = { "EDPS", "SALO", "SAVE" };
/// <summary>이 날짜 이후 개원한 병원은 사내 계정만(레거시 :1042-1048)</summary>
private const string VendorOnlyFrom = "20250501";
private const string VendorUid = "MSYS";
private const string ItDepartment = "EDPS";
#endregion
#region Methods
/// <summary>
/// 이 사용자가 '수정'을 쓸 수 있는가.
/// <paramref name="hospitalStartDate"/> 는 <c>HspStrDte</c>(YYYYMMDD) — 모르면 빈 문자열.
/// </summary>
public static bool CanModify(string hospitalCode, string departmentCode, string uidCod,
string hospitalStartDate)
{
if (Array.Exists(WideHospitals, h => string.Equals(h, hospitalCode, StringComparison.OrdinalIgnoreCase)))
{
return Array.Exists(WideDepartments,
d => string.Equals(d, departmentCode, StringComparison.OrdinalIgnoreCase));
}
if (string.CompareOrdinal(hospitalStartDate, VendorOnlyFrom) >= 0)
{
return string.Equals(uidCod, VendorUid, StringComparison.OrdinalIgnoreCase);
}
return string.Equals(departmentCode, ItDepartment, StringComparison.OrdinalIgnoreCase);
}
/// <summary>왜 막혔는지 — 화면이 "권한이 없습니다"만 말하면 누구에게 물어야 할지 모른다</summary>
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
{
if (Array.Exists(WideHospitals, h => string.Equals(h, hospitalCode, StringComparison.OrdinalIgnoreCase)))
{
return "이 병원에서는 전산실·총무·구매 부서만 수정할 수 있습니다.";
}
if (string.CompareOrdinal(hospitalStartDate, VendorOnlyFrom) >= 0)
{
return $"이 병원에서는 {VendorUid} 계정만 수정할 수 있습니다.";
}
return "전산실 소속만 수정할 수 있습니다.";
}
#endregion
}
@@ -0,0 +1,57 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 환자 데이터로 미리보기를 볼 수 있는 조건.
///
/// <b>레거시에서 옮기지 않는 것이 요점이다.</b> 레거시 서식생성기는
/// · 폼이 뜰 때마다 인가코드를 InputBox 로 묻고 <b>통과값 3개가 소스에 평문</b>으로 박혀 있고
/// (<c>frmSheetDesigner.vb:913</c>),
/// · 환자를 검색조건 없이 <b>운영 전체</b>에서 찾을 수 있고(<c>:636</c>),
/// · 누가 어느 환자를 열었는지 <b>남기지 않는다</b>(감사 0건).
/// 그 셋을 그대로 옮기면 신규 앱이 최악을 물려받는다.
///
/// 여기서 정하는 것은 <b>누가 볼 수 있는가</b>뿐이다. 검색 범위는 레거시와 같게 두기로 했고
/// (전체 검색 + 감사 로그), 그래서 <b>남기는 쪽</b>이 안전장치의 본체다 —
/// 조회 자체를 막지 않는 대신 흔적을 남긴다.
///
/// <b>부서 문(전산실)을 걸지 않는다.</b> 처음에는 <see cref="ModifyPolicy"/> 를 그대로 갖다 썼는데
/// 그건 레거시에 없는 제약이었다. 레거시가 부서로 가리는 툴은 <c>TK_MODIFY</c> 하나뿐이고
/// (<c>frmSheetDesigner.vb:1030-1060</c>, MSYS 전용인 <c>TK_ExportXML/ImportXML</c> 은 계정 문),
/// <c>TK_PREVIEW</c> 에는 어떤 가시성 조건도 걸리지 않는다 — 서식생성기를 열 수 있으면 쓸 수 있다.
///
/// 두 일의 무게가 다르기도 하다. 수정은 <b>지금 쓰이는 서식을 제자리에서 바꿔</b> 병원 전체에
/// 즉시 영향을 준다. 미리보기는 이미 그 사람이 볼 수 있는 기록을 <b>읽기만</b> 한다.
/// 여기에 부서 문을 걸면 서식을 만드는 사람이 자기 서식을 검증할 수 없게 되고,
/// 검증을 못 하면 값이 틀린 서식이 그대로 운영에 올라간다 — 막아서 더 위험해지는 쪽이다.
/// </summary>
public static class PatientPreviewPolicy
{
#region Methods
/// <summary>
/// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가 — <b>인증만</b> 요구한다.
///
/// 미인증이면 감사 로그에 남길 주체가 없고, 주체 없는 환자 조회는 남기면 안 되는 일이다.
/// 그것이 유일한 조건이다. 병원·부서 인자는 <b>일부러 받아 두고 쓰지 않는다</b> —
/// 병원별 규칙이 생기면 호출부를 고치지 않고 여기서 받을 수 있어야 하고,
/// 인자가 없으면 그때 호출부 전부를 다시 손대야 한다.
/// </summary>
public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod,
string hospitalStartDate)
=> uidCod.Length > 0;
/// <summary>왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다</summary>
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
=> uidCod.Length == 0
? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요."
: string.Empty;
/// <summary>
/// 감사 로그 한 줄 — 누가·언제·어느 내원을 열었는가.
///
/// 문구를 여기서 만드는 이유: 호출부마다 다르게 적으면 나중에 로그를 찾을 수 없다.
/// 환자 이름은 넣지 않는다 — 내원번호로 되짚을 수 있고, 로그 파일에 이름을 흘릴 이유가 없다.
/// </summary>
public static string AuditLine(string uidCod, string comNum)
=> $"[환자 미리보기] 사용자={uidCod} 내원={comNum}";
#endregion
}
@@ -0,0 +1,77 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 미리보기에서 눌러 본 체크·라디오 상태 — <b>문서를 건드리지 않는다</b>.
///
/// <b>왜 별도 상태인가.</b> 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
/// 실제로 눌러 보며 확인한다. 우리 미리보기도 눌러 볼 수 있어야 하지만, 그 클릭이 디자인을
/// 바꾸면 "확인하려고 눌렀는데 서식이 수정됨" 이 된다. 그래서 컨트롤 Id → 상태 사전으로만 들고
/// 렌더 시점에 덮어쓴다. 미리보기를 닫으면 사라진다.
///
/// <b>배타 규칙은 직계 부모다.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식이다 —
/// 즉 <b>패널 하나가 문항 하나</b>다. 여기서도 형제는 직계 부모의 자식만 본다.
/// </summary>
public sealed class PreviewCheckState
{
#region Member Fields
private readonly Dictionary<string, bool> overrides = new(StringComparer.Ordinal);
#endregion
#region Properties
/// <summary>눌러 본 것이 하나라도 있는가 — 렌더가 덮어쓸 필요가 있는지 판단용</summary>
public bool HasAny => overrides.Count > 0;
/// <summary>덮어쓸 상태 — 눌러 본 적 없으면 null</summary>
public IReadOnlyDictionary<string, bool> Overrides => overrides;
#endregion
#region Methods
/// <summary>이 컨트롤이 지금 체크된 상태인가 — 눌러 본 값이 없으면 서식의 기본값</summary>
public bool IsChecked(ControlElement element)
=> overrides.TryGetValue(element.Id, out var state)
? state
: string.Equals(element.Props.GetText("Checked"), "True", StringComparison.OrdinalIgnoreCase);
/// <summary>미리보기에서 누를 수 있는 타입인가</summary>
public static bool IsClickable(ControlElement element)
=> element.Type is "CheckBox" or "RadioButton"
// 레거시는 읽기전용(PreventEditing)이면 OnClick 자체를 무시한다
// (RadioButton.vb:271-279) — 값 대입은 되지만 클릭은 안 먹는다.
&& !string.Equals(element.Props.GetText("PreventEditing"), "True", StringComparison.OrdinalIgnoreCase);
/// <summary>
/// 눌렀다 — 체크박스는 반전, 라디오는 자기를 켜고 <b>직계 형제 라디오를 끈다</b>.
/// </summary>
/// <param name="target">누른 컨트롤</param>
/// <param name="siblings">그 컨트롤의 <b>직계 부모</b>가 담은 컨트롤 전부(자기 포함)</param>
public void Click(ControlElement target, IReadOnlyList<ControlElement> siblings)
{
if (!IsClickable(target))
{
return;
}
if (target.Type == "CheckBox")
{
overrides[target.Id] = !IsChecked(target);
return;
}
// 라디오 — 이미 켜져 있어도 다시 켠 상태로 둔다(레거시도 체크를 풀지 않는다).
// 끄는 것은 같은 부모의 다른 라디오를 누르는 것뿐이다.
foreach (var sibling in siblings)
{
if (sibling.Type == "RadioButton" && !ReferenceEquals(sibling, target))
{
overrides[sibling.Id] = false;
}
}
overrides[target.Id] = true;
}
/// <summary>눌러 본 것을 전부 잊는다 — 환자를 바꾸거나 서식을 다시 열 때</summary>
public void Clear() => overrides.Clear();
#endregion
}
+71
View File
@@ -0,0 +1,71 @@
using SheetMe.Core.Models;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 이 컨트롤이 <b>인쇄·미리보기에 나오는가</b>.
///
/// <b>왜 필요한가.</b> 레거시 런타임은 인쇄에서 이렇게 걸러낸다 —
/// <c>If Me.Visible = False OrElse mbPrintOutPut = False Then Return False</c>
/// (그 사실이 <see cref="ControlRegistry"/> 주석에 인용까지 되어 있다).
/// 그런데 우리 미리보기는 그 두 키를 <b>아무도 읽지 않았다</b>. 대신 디자이너 전용 플래그
/// (<c>ControlElement.Hidden</c>, <c>[JsonIgnore]</c>, 주석에 "레거시 Visible 과 별개")만 걸렀다.
/// 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
/// <b>저장 전 검증 수단이 거짓말을 하고 있었다.</b>
///
/// <b>키 이름이 타입마다 다르다.</b> 라벨만 소문자 <c>visible</c> 이다
/// (Label.vb 가 <c>Shadows Property visible</c> 로 <c>Control.Visible</c> 을 가리고 직렬화기가 그림자를 쓴다).
/// 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다.
/// 그래서 <b>타입이 선언한 키</b>를 카탈로그에서 찾아 그것만 읽는다 —
/// 둘 다 관용으로 읽으면, 대문자 Visible=False 인 라벨이 인쇄에서 빠진다.
/// 레거시는 그 라벨을 그대로 인쇄하므로 그건 우리가 만드는 새 차이다.
///
/// <b>모르는 값은 인쇄한다.</b> 키가 없거나 해석할 수 없으면 나온다 —
/// 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다(빠진 것은 아무도 못 본다).
/// </summary>
public static class PrintFilter
{
#region Methods
/// <summary>인쇄·미리보기에 나오는가</summary>
public static bool IsPrinted(ControlElement control)
=> IsOn(control, VisibleKeyOf(control.Type)) && IsOn(control, "PrintOutPut");
/// <summary>
/// 이 타입이 실제로 쓰는 '표시' 키. 카탈로그가 정답을 갖고 있으므로 하드코딩하지 않는다 —
/// 타입이 늘거나 키가 바뀌면 여기가 따라온다.
/// </summary>
public static string VisibleKeyOf(string type)
{
var descriptor = ControlRegistry.Find(type);
if (descriptor is null)
{
return "Visible";
}
foreach (var property in descriptor.Properties)
{
if (string.Equals(property.Key, "visible", StringComparison.Ordinal)
|| string.Equals(property.Key, "Visible", StringComparison.Ordinal))
{
return property.Key;
}
}
return "Visible";
}
/// <summary>
/// 레거시가 쓰는 불리언 표기는 WinForms TypeConverter 의 <c>"True"</c>/<c>"False"</c> 다.
/// 운영 데이터에는 대소문자·공백이 섞여 있어 관용으로 읽는다.
/// </summary>
private static bool IsOn(ControlElement control, string key)
{
var raw = control.Props.GetText(key)?.Trim();
if (raw is null || raw.Length == 0)
{
return true;
}
return !(raw.Equals("False", StringComparison.OrdinalIgnoreCase)
|| raw.Equals("0", StringComparison.Ordinal)
|| raw.Equals("No", StringComparison.OrdinalIgnoreCase));
}
#endregion
}
@@ -0,0 +1,202 @@
using System.Text;
using System.Text.RegularExpressions;
namespace SheetMe.Core.Catalog;
/// <summary>
/// 치환 변수의 값을 내주는 쪽 — 미리보기는 환자 문맥이, 시험 실행은 표본이 맡는다.
/// </summary>
public interface IQueryVariableSource
{
/// <summary>
/// 스칼라 속성 값. <b>null 은 "이 이름을 모른다"</b>, 빈 문자열은 "안다, 값이 비었다"다.
/// 둘은 SQL 에서 같게 처리되지만(레거시가 그렇다) 사람에게는 다르게 말해야 한다.
/// </summary>
string? Scalar(string className, string property);
/// <summary>DataRow 속성. null 은 그 행을 모른다는 뜻</summary>
IReadOnlyDictionary<string, string>? Row(string className, string property);
}
/// <summary>치환 결과 한 건 — 무엇이 무엇으로 바뀌었는가</summary>
/// <param name="Token">원문 토큰(<c>&lt;&lt;…&gt;&gt;</c> 포함)</param>
/// <param name="Value">박힌 값</param>
/// <param name="Kind">어떻게 판정됐는가</param>
public readonly record struct QuerySubstitutionHit(string Token, string Value, QuerySubstitutionKind Kind);
/// <summary>치환 판정</summary>
public enum QuerySubstitutionKind
{
/// <summary>값이 있었다</summary>
Value,
/// <summary>이름은 아는데 값이 비었다 — 레거시도 빈 문자열로 박는다</summary>
Empty,
/// <summary>이름을 모른다(아직 안 옮긴 속성) — 레거시는 빈 문자열, 우리는 그 사실을 말한다</summary>
Unknown,
/// <summary>접두어가 세 클래스 중 어느 것도 아니다 — 레거시는 토큰을 <b>그대로 남긴다</b>(ORA 구문오류)</summary>
NotAVariable,
}
/// <summary>치환을 마친 SQL 과 그 내역</summary>
public sealed record QuerySubstitutionResult(
string Sql, IReadOnlyList<QuerySubstitutionHit> Hits)
{
/// <summary>SQL 에 남은 토큰이 있는가 — 있으면 실행하면 ORA 구문오류다</summary>
public bool HasLeftover => Hits.Any(h => h.Kind == QuerySubstitutionKind.NotAVariable);
/// <summary>아직 옮기지 않은 속성들 — 화면이 "왜 비었나"를 설명할 때 쓴다</summary>
public IReadOnlyList<string> UnknownTokens
=> Hits.Where(h => h.Kind == QuerySubstitutionKind.Unknown).Select(h => h.Token).Distinct().ToList();
/// <summary>
/// 따옴표가 섞여 들어간 값들 — <b>레거시는 값을 원문 그대로 박는다</b>.
/// 환자 이름에 <c>'</c> 가 있으면 SQL 이 깨지거나 문장이 바뀐다.
/// 막지는 않는다(레거시와 같은 SQL 을 만들어야 미리보기가 거짓말을 하지 않는다) —
/// 대신 <b>보이게</b> 한다. 실행 쪽은 SELECT/WITH 만 허용하는 문이 따로 있다.
/// </summary>
public IReadOnlyList<string> QuotedValues
=> Hits.Where(h => h.Value.Contains('\'') || h.Value.Contains(';'))
.Select(h => h.Token).Distinct().ToList();
}
/// <summary>
/// MDataTable 쿼리의 <c>&lt;&lt;…&gt;&gt;</c> 치환 — 레거시 <c>clsMDataTable.ConvertQuery</c> 를 옮긴 것
/// (<c>[003]EMR\[002]UserControl\MDataTable\clsMDataTable.vb:24-71, :113</c>).
///
/// <b>규약을 그대로 지킨다.</b> 여기서 한 칸이라도 다르게 만들면 미리보기가 운영과 다른 SQL 을 돌린다 —
/// "미리보기에서는 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다.
/// <list type="number">
/// <item>토큰이 세 클래스 <b>전체 이름</b> 중 하나로 시작하는지 본다(StartsWith).
/// 축약형은 치환되지 않고 토큰이 SQL 에 남는다.</item>
/// <item>나머지를 <c>^([a-zA-Z_]\w*)(?:\.item\("([^"]+)"\)|\("([^"]+)"\))?$</c> 로 해석한다 —
/// 스칼라와 DataRow 접근 2형태.</item>
/// <item><c>item</c> 은 <b>소문자만</b>이다(정규식이 대소문자를 구분한다).</item>
/// <item>값은 <b>원문 그대로</b> 박는다 — 따옴표를 붙여 주지 않는다.</item>
/// <item>값을 못 만들면 <b>빈 문자열</b>로 박는다(:67-71, :138). 조용히 사라진다.</item>
/// </list>
///
/// 순수 함수로 둔다. 갈래가 많고(스칼라/DataRow 2형태/미지원/접두어 불일치) 실DB 로는
/// 한 갈래씩 확인할 수 없다.
/// </summary>
public static class QuerySubstitution
{
#region Member Fields
/// <summary>런타임 호스트의 속성 선언형 — <c>ucLoadSheetBase.vb:7619-7631</c></summary>
public const string PatientClass = "M.CMM.HISOperatingInfo.bzPatientInfo";
public const string WorkClass = "M.CMM.HISOperatingInfo.bzWorkInfo";
public const string SheetClass = "M.EMR.SheetLoadOperatingInfo.bzSheetInfo";
/// <summary>긴 것부터 본다 — 접두어가 서로 겹치면 짧은 쪽이 먼저 맞아 나머지 해석이 깨진다</summary>
private static readonly string[] Classes = { PatientClass, WorkClass, SheetClass };
/// <summary>레거시와 같은 정규식. <c>item</c> 소문자, 경로 한 단계만</summary>
private static readonly Regex Member = new(
@"^([a-zA-Z_]\w*)(?:\.item\(""([^""]+)""\)|\(""([^""]+)""\))?$",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
#endregion
#region Methods
/// <summary>치환한다 — SQL 은 실행할 수 있는 문장이 되고, 내역은 왜 그렇게 됐는지 말한다</summary>
/// <param name="emptyPlaceholder">
/// 값이 빈 자리에 대신 넣을 것. 기본은 null 이고 그때는 <b>레거시와 같이 빈 문자열</b>이 된다.
///
/// <b>구문 검사용 탈출구다.</b> 빈 문자열로 치우면 <c>WHERE X = </c> 가 되어
/// ORA-00936(누락된 표현식)이 난다 — 즉 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을
/// 통과할 수가 없다.</b> 정작 그 기능이 존재하는 이유가 그런 쿼리들이다.
/// <c>"NULL"</c> 을 주면 따옴표 안('<c>'&lt;&lt;…&gt;&gt;'</c>')이든 밖이든 문법이 성립하고
/// 0행이 나오므로 <b>테이블·컬럼 오타와 구문 오류</b>만 걸러 낼 수 있다.
///
/// 실행 경로(미리보기)는 이것을 쓰지 않는다 — 거기서는 레거시와 같은 SQL 이어야 한다.
/// </param>
public static QuerySubstitutionResult Apply(string sql, IQueryVariableSource source,
string? emptyPlaceholder = null)
{
var text = sql ?? string.Empty;
var hits = new List<QuerySubstitutionHit>();
var output = new StringBuilder(text.Length);
var at = 0;
while (at < text.Length)
{
var open = text.IndexOf("<<", at, StringComparison.Ordinal);
if (open < 0)
{
output.Append(text, at, text.Length - at);
break;
}
var close = text.IndexOf(">>", open + 2, StringComparison.Ordinal);
if (close < 0)
{
// 닫히지 않은 토큰 — 손대지 않는다. 레거시도 여기서 멈춘다
output.Append(text, at, text.Length - at);
break;
}
output.Append(text, at, open - at);
var token = text[open..(close + 2)];
var inner = text[(open + 2)..close];
var (value, kind) = Resolve(inner, source);
// 내역은 <b>실제 판정</b>을 그대로 남긴다 — 자리표시로 채운 것을 "값이 있었다"로 적으면
// 화면이 "값이 나왔다"고 말하게 되고, 그게 이 기능에서 가장 위험한 거짓말이다.
hits.Add(new QuerySubstitutionHit(token, value, kind));
if (value.Length == 0 && emptyPlaceholder is { Length: > 0 } placeholder
&& kind != QuerySubstitutionKind.NotAVariable)
{
value = placeholder;
}
// 변수가 아니면 토큰을 그대로 남긴다 — 레거시가 그렇고, 남은 토큰은 ORA 구문오류가 된다.
// 조용히 지우면 <b>조건이 사라진 SQL</b>이 돌아 엉뚱한 행이 나온다. 그게 더 위험하다.
output.Append(kind == QuerySubstitutionKind.NotAVariable ? token : value);
at = close + 2;
}
return new QuerySubstitutionResult(output.ToString(), hits);
}
/// <summary>토큰 안쪽을 값으로</summary>
private static (string Value, QuerySubstitutionKind Kind) Resolve(string inner, IQueryVariableSource source)
{
var className = Array.Find(Classes, c => inner.StartsWith(c, StringComparison.Ordinal));
if (className is null)
{
return (string.Empty, QuerySubstitutionKind.NotAVariable);
}
var rest = inner[className.Length..].TrimStart('.');
var match = Member.Match(rest);
if (!match.Success)
{
// 접두어는 맞는데 경로가 규약 밖이다(중첩 접근 등) — 레거시는 빈 문자열로 박는다
return (string.Empty, QuerySubstitutionKind.Unknown);
}
var property = match.Groups[1].Value;
var column = match.Groups[2].Success ? match.Groups[2].Value
: match.Groups[3].Success ? match.Groups[3].Value
: null;
if (column is null)
{
var scalar = source.Scalar(className, property);
return scalar is null
? (string.Empty, QuerySubstitutionKind.Unknown)
: (scalar, scalar.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
}
var row = source.Row(className, property);
if (row is null)
{
return (string.Empty, QuerySubstitutionKind.Unknown);
}
// 행은 있는데 그 컬럼이 없다 — 레거시는 예외를 먹고 빈 문자열로 박는다.
// "행이 없다"와 구분해야 한다: 전자는 서식의 컬럼명 오타, 후자는 아직 안 옮긴 것이다.
if (!row.TryGetValue(column, out var cell))
{
return (string.Empty, QuerySubstitutionKind.Empty);
}
return (cell, cell.Length == 0 ? QuerySubstitutionKind.Empty : QuerySubstitutionKind.Value);
}
#endregion
}
+235
View File
@@ -0,0 +1,235 @@
namespace SheetMe.Core.Catalog;
/// <summary>
/// 주민번호 보정과 그로부터 나오는 값들 — 복호화 <b>뒤</b>에 붙는 순수 계산.
///
/// 복호화 자체는 DB 가 한다(<c>CRYPTO_AES256.DEC_AES</c>). 앱은 키를 갖지 않는다 —
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
///
/// <b>순수 함수로 두는 이유.</b> 갈래가 병원·국적·생년월일에 걸쳐 있고 실DB 로는
/// 한 갈래씩 확인할 수 없다. 그리고 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다.
/// </summary>
public static class ResidentNumber
{
#region Member Fields
/// <summary>주민번호에 기관기호가 들어가는 병원 — bzPatientInfo.vb:556</summary>
private static readonly string[] InstitutionPrefixHospitals = { "SRCH", "JINJU" };
#endregion
#region Methods
/// <summary>
/// 복호화된 주민번호를 실제로 쓸 형태로 — 레거시 <c>bzPatientInfo.vb:546-580</c>.
///
/// <b>보정 ①(기관기호).</b> SRCH·JINJU 는 앞 6자리를 <paramref name="birthDay"/> 의
/// YYMMDD 로 갈아 끼운다.
///
/// 원본은 <c>If Not DateValidCheck(sResNum.Substring(1, 6))</c> 로 감싸 있지만
/// <b>그 조건은 항상 참이다</b> — <c>DateValidCheck</c> 는 길이가 8·12·14·17 이 아니면
/// 무조건 False 를 돌려주는데(clsCommonLib.vb DateValidCheck) 호출부는 6자리를 넘긴다.
/// 그래서 조건문이 아무것도 걸러 내지 않는다.
///
/// 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 <b>레거시와 다른 번호</b>가 나온다 —
/// 앞자리가 유효한 날짜인 환자에서 갈린다. 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다.
/// 바로잡을지는 별도 결정 사항이고, 그때 이 주석이 근거가 된다.
///
/// <b>보정 ②(외국인).</b> 7번째 자리가 <c>0</c> 또는 <c>9</c> 이고 국적코드가 있으면
/// 생년월일의 세기로 그 자리를 바꾼다 — 0 → 19세기대면 6, 아니면 8 / 9 → 5 또는 7.
/// </summary>
/// <param name="decrypted">복호화(또는 평문) 주민번호 13자리</param>
/// <param name="birthDay">P_PatInf.PatBthDay — YYYYMMDD</param>
/// <param name="nationCode">P_PatInf.PatNatCod — 비어 있으면 외국인 보정을 하지 않는다</param>
/// <param name="hospitalCode">HspCfg.HspCod</param>
public static string Normalize(string? decrypted, string? birthDay, string? nationCode,
string? hospitalCode)
{
var value = Clean(decrypted);
var birth = Clean(birthDay);
if (value.Length < 13)
{
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
return value;
}
// ① 기관기호 — 조건이 항상 참이므로 조건 자체를 옮기지 않는다(위 설명)
if (Array.Exists(InstitutionPrefixHospitals,
h => string.Equals(h, Clean(hospitalCode), StringComparison.OrdinalIgnoreCase))
&& birth.Length >= 8)
{
value = birth[2..8] + value[6..13];
}
// ② 외국인 — 국적코드가 있어야 하고, 세기 판정에 생년월일이 필요하다
var seventh = value[6];
if ((seventh == '0' || seventh == '9') && Clean(nationCode).Length > 0 && birth.Length >= 2)
{
var nineteenth = birth[..2] == "19";
var digit = seventh == '0'
? (nineteenth ? '6' : '8')
: (nineteenth ? '5' : '7');
value = value[..6] + digit + value[7..13];
}
return value;
}
/// <summary>
/// 성별 — 7번째 자리 mod 2 (<c>clsCommonLib.vb:613-656</c> GetSexAge).
/// 짝수면 F, 홀수면 M. 숫자가 아니면 빈 문자열 —
/// 원본은 <c>CLong</c> 에서 예외가 나고 삼켜져 값이 비는 것과 같다.
/// </summary>
public static string SexOf(string? residentNumber)
{
var value = Clean(residentNumber);
if (value.Length < 7 || !char.IsAsciiDigit(value[6]))
{
return string.Empty;
}
return (value[6] - '0') % 2 == 0 ? "F" : "M";
}
/// <summary>한글 성별 — F 는 여, M 은 남(<c>bzDataInterface.vb:305-316</c>)</summary>
public static string SexKoreanOf(string? residentNumber) => SexOf(residentNumber) switch
{
"F" => "여",
"M" => "남",
_ => string.Empty,
};
/// <summary>
/// 하이픈 형태 — <c>bzDataInterface.vb:91-102</c>.
/// 구분자가 <b>" - "</b>(양쪽 공백)이다. 공백을 빼면 레거시 서식과 폭이 달라진다.
/// </summary>
public static string WithDash(string? residentNumber)
{
var value = Clean(residentNumber);
return value.Length >= 13 ? $"{value[..6]} - {value[6..13]}" : value;
}
private static string Clean(string? value) => (value ?? string.Empty).Trim();
/// <summary>
/// 생년월일부터 오늘까지의 <b>개월수</b> — 레거시 <c>PAT_나이_개월수</c>
/// (<c>DateDiff(Month, PatBthDay, SystemDate) - 1</c>).
///
/// VB 의 <c>DateDiff(Month, ...)</c> 는 날짜를 보지 않고 <b>월 경계만 센다</b>
/// (연차×12 + 월차). 거기서 1을 뺀 값이 태그의 결과다.
/// 일수로 환산하지 않는다 — 환산하면 레거시와 다른 수가 나온다.
/// </summary>
public static string MonthsSince(string? birthDay, string? todayYmd)
{
var birth = Clean(birthDay);
var today = Clean(todayYmd);
if (birth.Length < 6 || today.Length < 6
|| !int.TryParse(birth[..4], out var by) || !int.TryParse(birth.Substring(4, 2), out var bm)
|| !int.TryParse(today[..4], out var ty) || !int.TryParse(today.Substring(4, 2), out var tm))
{
return string.Empty;
}
return (((ty - by) * 12) + (tm - bm) - 1).ToString();
}
/// <summary>
/// "N개월 N일" — 레거시 <c>PAT_나이_00개월_00일</c>.
///
/// <b>달력이 아니라 30일로 나눈다</b>(<c>days \ 30</c>, <c>days Mod 30</c>).
/// 실제 개월수와 다르지만 레거시가 그렇게 찍는다 — 달력으로 고치면 값이 달라진다.
/// </summary>
public static string MonthsDaysSince(string? birthDay, string? todayYmd)
{
var days = DaysSince(birthDay, todayYmd);
return days is null ? string.Empty : $"{days / 30}개월 {days % 30}일";
}
/// <summary>
/// 생일부터 기준일까지 총 일수 — 개월·일 분해 태그 4종의 공통 재료.
/// 날짜를 못 읽으면 null(레거시는 예외가 전파돼 노란색이 되던 자리다).
/// </summary>
public static int? DaysSince(string? birthDay, string? todayYmd)
{
var birth = Clean(birthDay);
var today = Clean(todayYmd);
if (birth.Length < 8 || today.Length < 8
|| !DateTime.TryParseExact(birth[..8], "yyyyMMdd", null,
System.Globalization.DateTimeStyles.None, out var from)
|| !DateTime.TryParseExact(today[..8], "yyyyMMdd", null,
System.Globalization.DateTimeStyles.None, out var to))
{
return null;
}
return (int)(to - from).TotalDays;
}
/// <summary>
/// 만 나이 — 레거시 <c>AgeCheck</c>(clsCommonLib.vb:665-974)의 이식.
///
/// <b>세기 접두 규칙</b>(주민번호 7번째 자리): 1·2→19xx, 3·4→20xx,
/// 5·6(외국인)→ 출생 2자리가 20 미만이면 20xx, 아니면 19xx 에 <b>월일을 0101 로
/// 강제</b>(행려·외국인은 앞 6자리가 생년월일이 아닐 수 있다는 레거시 판단),
/// 7·8→ 서버 오늘의 연도 2자리와 비교해 19/20, 그 외(0·9)→18xx.
/// REDCROSS(적십자)·BSYD·BSGH 의 병원 특례는 옮기지 않았다 — 전부 Case Else 갈래다.
///
/// <b>만 나이 규칙</b>: 기준년-출생년, 생일이 아직 안 지났으면 -1(생일 당일은 차감 없음 —
/// 2024-09-25 이후 전 병원 규칙). 나이가 1 이하면 총 일수차가 <b>365 미만</b>일 때 0
/// (고정 365 — 윤년 경계의 하루 오차까지 레거시 그대로).
///
/// 레거시는 잘못된 입력에서 오류창을 띄우고 0 을 돌려줬다 — 여기서는 조용히 0 이다.
/// </summary>
public static long AgeOf(string? residentNumber, string? toDate, string serverToday)
{
var res = Clean(residentNumber);
if (res.Length < 13 || serverToday.Length < 8)
{
return 0; // 레거시도 빈 값은 0, 짧은 값은 예외→0 이었다
}
string birth8;
switch (res[6])
{
case '1' or '2':
birth8 = "19" + res[..6];
break;
case '3' or '4':
birth8 = "20" + res[..6];
break;
case '5' or '6':
if (!int.TryParse(res[..2], out var left2))
{
return 0;
}
birth8 = left2 < 20 ? "20" + res[..6] : "19" + res[..2] + "0101";
break;
case '7' or '8':
if (!int.TryParse(res[..2], out var yy2)
|| !int.TryParse(serverToday.Substring(2, 2), out var nowYy))
{
return 0;
}
birth8 = (nowYy < yy2 ? "19" : "20") + res[..6];
break;
default:
birth8 = "18" + res[..6];
break;
}
var to = Clean(toDate);
if (to.Length < 8)
{
to = serverToday;
}
if (!int.TryParse(to[..4], out var toYear) || !int.TryParse(birth8[..4], out var birthYear)
|| !int.TryParse(birth8.Substring(4, 4), out var birthMmdd)
|| !int.TryParse(to.Substring(4, 4), out var toMmdd))
{
return 0;
}
long age = toYear - birthYear;
if (birthMmdd > toMmdd)
{
age -= 1;
}
if (age <= 1)
{
var days = DaysSince(birth8, to[..8]);
return days is null ? 0 : days < 365 ? 0 : 1;
}
return age;
}
#endregion
}
@@ -0,0 +1,132 @@
namespace SheetMe.Core.Catalog;
/// <summary>이미지 경로를 어떻게 얻었는가 — 화면·진단이 사유를 정확히 말하기 위해 구분한다</summary>
public enum SignatureImageOrigin
{
/// <summary>쓸 수 있는 파일이 없다</summary>
None,
/// <summary>레거시 EMR 이 이미 내려받아 둔 캐시 파일</summary>
LegacyCache,
/// <summary>파일서버의 공유 폴더(UNC)로 직접 읽었다</summary>
Share,
/// <summary>DB 가 준 경로 자체가 이 단말에서 바로 열린다</summary>
DirectPath,
}
/// <summary>이미지 경로 판정 결과</summary>
/// <param name="Origin">어디서 얻었는가</param>
/// <param name="LocalPath">열 수 있는 파일의 전체 경로 — 없으면 빈 문자열</param>
/// <param name="FileName">원격 경로의 파일명(사유 문구·캐시 조회용)</param>
public readonly record struct SignatureImageResult(
SignatureImageOrigin Origin, string LocalPath, string FileName);
/// <summary>
/// DB 가 준 <b>원격 경로</b>에서 실제로 열 수 있는 <b>로컬 파일</b>을 정한다.
///
/// <b>왜 이 규칙인가.</b> 레거시는 원격 경로를 파일서버에서 내려받아 로컬 경로를 돌려준다
/// (bzDataInterface.vb 싸인 17종 공통). 그 전송 계층은 이 병원 설정에서 ActiveX OCX
/// (<c>AxBITSFTLib.AxbitSFT</c>, M_DtlMst 'EMR_FileTransfer' Type=1)라 .NET 으로 재현할 수 없다 —
/// OCX 내부 프로토콜이 코드에 없고, 우리 배포는 서드파티·ActiveX 를 금지한다.
///
/// 그래서 <b>받지 않고 찾는다</b>: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
/// (<c>{설치경로}\Tmp\DownLoadedFiles\Signature\{파일명}</c> — 레거시가 쓰는 바로 그 폴더),
/// ②DB 경로 자체가 이 단말에서 열리는 경우(로컬 절대경로·UNC 공유). 둘 다 아니면 없음이다.
///
/// 이 판정을 순수 함수로 두는 이유는 DB·파일서버 없이 규칙을 검증하기 위해서다.
/// </summary>
public static class SignatureImagePath
{
#region Methods
/// <summary>레거시가 쓰는 캐시 폴더 — 설치 경로가 없으면 빈 문자열</summary>
public static string CacheFolder(string? installPath)
=> string.IsNullOrWhiteSpace(installPath)
? string.Empty
// 레거시는 설치 경로가 없어도 문자열을 그대로 이어 붙여 "\Tmp\..." 라는
// 루트 상대경로를 만든다(clsCommonLib.vb:271-287). 그 결함은 옮기지 않는다.
: System.IO.Path.Combine(installPath.Trim(), "Tmp", "DownLoadedFiles", "Signature");
/// <summary>
/// 원격 경로 → 열 수 있는 로컬 파일.
/// </summary>
/// <param name="remotePath">DB 가 준 경로(M_UidMst.UidImgPth 등)</param>
/// <param name="installPath">HIS 설치 경로(ServerInfo ini 의 Install\Path)</param>
/// <param name="exists">파일 존재 판정 — 테스트가 갈아 끼운다</param>
/// <param name="shareMap">
/// 서버 경로 → UNC 공유 대응(<c>"D:\MsystechHIS=\\서버\MsystechHIS"</c> 형태).
/// 접두가 <b>긴 것부터</b> 맞춰 본다 — 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다.
/// </param>
public static SignatureImageResult Resolve(string? remotePath, string? installPath,
Func<string, bool>? exists = null, IReadOnlyList<string>? shareMap = null)
{
var remote = (remotePath ?? string.Empty).Trim();
if (remote.Length == 0)
{
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, string.Empty);
}
// 레거시는 역슬래시 기준으로 파일명을 뗀다(경로 구분자가 서버 규약이라 Path 로 자르지 않는다)
var fileName = remote.Split('\\', '/').Last();
var probe = exists ?? System.IO.File.Exists;
var folder = CacheFolder(installPath);
if (folder.Length > 0 && fileName.Length > 0)
{
var cached = System.IO.Path.Combine(folder, fileName);
if (probe(cached))
{
return new SignatureImageResult(SignatureImageOrigin.LegacyCache, cached, fileName);
}
}
// 캐시에 없으면 파일서버 공유(UNC)로 직접 읽는다 — 설정된 접두 대응을 쓴다.
// 이 경로가 이 작업의 목적이다: OCX 전송을 재현하는 대신 같은 파일을 공유로 본다.
if (MapToShare(remote, shareMap) is { Length: > 0 } shared && probe(shared))
{
return new SignatureImageResult(SignatureImageOrigin.Share, shared, fileName);
}
// 그래도 없으면 원격 경로가 이 단말에서 그대로 열리는지 본다
// (서버에서 실행하거나 같은 구조로 마운트된 단말)
if (probe(remote))
{
return new SignatureImageResult(SignatureImageOrigin.DirectPath, remote, fileName);
}
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, fileName);
}
/// <summary>
/// 서버 경로를 UNC 로 바꾼다 — 대응이 없으면 빈 문자열.
/// 대소문자를 무시하고, 접두가 <b>긴 것부터</b> 맞춘다.
/// </summary>
public static string MapToShare(string? remotePath, IReadOnlyList<string>? shareMap)
{
var remote = (remotePath ?? string.Empty).Trim();
if (remote.Length == 0 || shareMap is null || shareMap.Count == 0)
{
return string.Empty;
}
var best = string.Empty;
var bestLen = -1;
foreach (var entry in shareMap)
{
var at = entry.IndexOf('=', StringComparison.Ordinal);
if (at <= 0 || at == entry.Length - 1)
{
continue; // "서버접두=UNC접두" 가 아니면 무시한다(설정 오타를 값으로 바꾸지 않는다)
}
var from = entry[..at].Trim().TrimEnd('\\');
var to = entry[(at + 1)..].Trim().TrimEnd('\\');
if (from.Length == 0 || to.Length == 0 || from.Length <= bestLen)
{
continue;
}
if (remote.StartsWith(from + "\\", StringComparison.OrdinalIgnoreCase))
{
best = to + remote[from.Length..];
bestLen = from.Length;
}
}
return best;
}
#endregion
}
@@ -0,0 +1,270 @@
namespace SheetMe.Core.Catalog;
/// <summary>태그 값을 이 자리에서 만들 수 있는가 — 못 만든다면 왜</summary>
public enum TagPreviewKind
{
/// <summary>서버 시각으로 만든다</summary>
Clock,
/// <summary>로그인 사용자 정보로 만든다</summary>
LoginUser,
/// <summary>환자·내원 문맥이 있어야 한다 — 서식생성기에는 없다</summary>
PatientContext,
/// <summary>값이 아니라 목록(표)을 돌려준다 — 콤보를 채우는 용도</summary>
Table,
/// <summary>파일서버 이미지 경로를 돌려준다 — 싸인·직인</summary>
Image,
/// <summary>레거시가 항상 빈 값을 돌려준다</summary>
Dead,
/// <summary>태그가 아니라 레거시 내부 함수 이름이다</summary>
NotATag,
}
/// <summary>
/// 태그의 값을 서식생성기에서 만들 수 있는지, 없다면 왜인지.
///
/// <b>왜 표로 박는가.</b> 판정 근거는 레거시 함수 본문이 환자·내원 객체를 참조하는지 여부다.
/// 그건 태그 이름에도 접두어에도 컨트롤 타입에도 들어 있지 않다 —
/// <c>ETC_현재일자</c> 와 <c>ETC_요양기관명칭_병원명</c> 은 같은 접두어인데 한쪽만 만들 수 있다.
/// 이름 규칙으로 추정하면 절반만 맞는다. 그래서 실측 결과를 그대로 옮겨 적는다.
///
/// <b>값보다 사유가 본체다.</b> 실제로 값이 나오는 것은 384종 중 16종(4%)뿐이다.
/// 그러나 나머지에 대해 "왜 안 나오는가"는 지금 어디서도 얻을 수 없는 정보다 —
/// 실사용 3위인 <c>ETC_요양기관명칭_병원명</c>(303건)이 환자 내원을 타고 병원을 푼다는 것도,
/// 표를 돌려주는 태그와 죽은 태그와 태그가 아닌 내부 함수가 목록에 섞여 있다는 것도.
/// </summary>
public static class TagPreviewCatalog
{
#region Member Fields
/// <summary>서버 시각으로 만드는 것 — 값은 포맷 지시자</summary>
private static readonly Dictionary<string, ClockFormat> ClockTags = new(StringComparer.Ordinal)
{
["ETC_현재일자"] = ClockFormat.Date,
["ETC_현재일시"] = ClockFormat.DateTime,
["ETC_현재일시_MaskBox"] = ClockFormat.DateTimeMask,
// "년" 접미가 붙는다(bzDataInterface.vb:16923 — Data2Format(...) + "년").
// 처음에 접미 없는 Year 로 넣어 레거시와 다른 값("2026")이 나갔다 —
// 형제 태그 ETC_현재일시_1_년(:16929)은 접미가 <b>없으므로</b> 둘을 갈라야 한다.
["ETC_현재년도"] = ClockFormat.YearSuffixed,
["ETC_현재시간"] = ClockFormat.Time,
["ETC_현재일자_영문"] = ClockFormat.DateEnglish,
["ETC_현재일시_1_년"] = ClockFormat.Year,
["ETC_현재일시_2_월"] = ClockFormat.Month,
["ETC_현재일시_3_일"] = ClockFormat.Day,
["ETC_현재일시_4_몇시"] = ClockFormat.Hour,
["ETC_현재일시_5_몇분"] = ClockFormat.Minute,
};
/// <summary>로그인 사용자 정보로 만드는 것 — 값은 어느 항목인지</summary>
private static readonly Dictionary<string, UserField> UserTags = new(StringComparer.Ordinal)
{
["ETC_로그인_사용자명"] = UserField.Name,
["ETC_로그인_사용자ID"] = UserField.Id,
["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName,
["ETC_로그인_사용자연락처"] = UserField.MobilePhone,
["ETC_로그인_원내연락처"] = UserField.OfficePhone,
// 세션 사용자 행(M_UidMst + 직종 조인)에서 나온다 — 환자 문맥이 필요 없다.
// 근무부서는 레거시가 개원일(HspStrDte>=20220701)로 DepOfcNam/DepKorNam 을 가르는데
// 개원일은 세션에 없어 빈 값 결정이라 DepKorNam 갈래로 간다(OCM_진료과와 같은 결정).
["ETC_로그인_의사면허번호"] = UserField.License,
["ETC_로그인_전문의번호"] = UserField.Specialist,
["ETC_로그인_직종"] = UserField.Job,
["ETC_로그인_근무부서"] = UserField.Department,
["ETC_로그인_근무부서_사용자명"] = UserField.DepartmentAndName,
// 이름은 "부서번호"지만 값은 부서 <b>전화번호</b>다(bzDataInterface.vb:17287-17313 — DepTelNum)
["ETC_접속유저_부서번호"] = UserField.DepPhone,
};
/// <summary>
/// 못 만드는 것 중 <b>환자 문맥 말고 다른 이유</b>인 것들.
/// 여기 없는 것은 전부 <see cref="TagPreviewKind.PatientContext"/> 다.
/// </summary>
private static readonly Dictionary<string, TagPreviewKind> Exceptions = new(StringComparer.Ordinal)
{
// 레거시가 항상 빈 문자열을 돌려준다(본문이 Return "")
["OCM_수술일자_Refer"] = TagPreviewKind.Dead,
// 본문 34줄이 전부 주석 처리되고 Return "" 만 남았다(bzDataInterface.vb:15352).
// 운영에서 2건 쓰이는데, 이 표에 없으면 화면이 "환자 정보가 있으면 나온다"고 말한다 —
// 환자를 골라도 영원히 안 나온다. 사유를 정직하게 하려고 넣는다.
["ETC_건강보험증번호_Refer"] = TagPreviewKind.Dead,
// 값은 UidNam 을 담고 조건은 UidCod 로 건다(bzDataInterface.vb:17262·17272) —
// 실무상 0건이라 레거시에서 늘 빈칸이던 태그다. 고치면 없던 값이 갑자기 채워지므로
// 출시 전 합의 전까지 레거시 동작(항상 빈 값)을 유지한다.
["ETC_로그인_직급"] = TagPreviewKind.Dead,
// 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다.
// 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다.
["퇴원약DT"] = TagPreviewKind.NotATag,
["GetOprInfDT"] = TagPreviewKind.NotATag,
["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag,
["GetNurseEmrDT"] = TagPreviewKind.NotATag,
["GetNurseShtCod"] = TagPreviewKind.NotATag,
["GetNurseShtCod_GCRCH"] = TagPreviewKind.NotATag,
["GetNurseShtCod_GNBEDRO"] = TagPreviewKind.NotATag,
["GetNurseShtCod_SRH"] = TagPreviewKind.NotATag,
// 이름은 List 로 끝나지만 <b>값 한 줄</b>을 돌려준다(알러지는 CRLF 결합 문자열,
// 당뇨식칼로리는 최신 1건) — 아래 "List 끝 = 표" 규칙보다 먼저 잡아야
// 환자 해석기가 만든 실값이 "표를 돌려주는 태그" 사유에 가려지지 않는다.
["OCM_알러지_List"] = TagPreviewKind.PatientContext,
["OCM_당뇨식칼로리_List"] = TagPreviewKind.PatientContext,
// 반대로 이름이 규칙에 안 걸리는데 <b>DataTable</b> 을 돌려주는 콤보 채움용 4종
// (bzDataInterface.vb:16797-16890) — 미리보기 한 줄 값이 아니다.
["ETC_전체진료과_한글명"] = TagPreviewKind.Table,
["ETC_전체진료과_영문명"] = TagPreviewKind.Table,
["ETC_의사List_이름순"] = TagPreviewKind.Table,
["ETC_진료과별의사List_이름순"] = TagPreviewKind.Table,
// 이름이 "목록"으로 끝나 규칙(List/Datatable 끝)에 안 걸리는 DataTable 반환
// (bzDataInterface.vb:15414 — 수술실(OPR) 재직 간호사 이름 표, 콤보 채움용)
["ETC_수술간호사목록"] = TagPreviewKind.Table,
};
#endregion
#region Methods
/// <summary>이 태그의 값을 어떻게 얻는가 — 못 얻으면 그 이유</summary>
public static TagPreviewKind KindOf(string tag)
{
if (ClockTags.ContainsKey(tag))
{
return TagPreviewKind.Clock;
}
if (UserTags.ContainsKey(tag))
{
return TagPreviewKind.LoginUser;
}
if (Exceptions.TryGetValue(tag, out var known))
{
return known;
}
// 이름 끝으로 갈리는 두 갈래는 규칙이 확실하다 — 표에 나열하지 않는다.
//
// <b>Inital 은 오타가 아니라 카탈로그의 실제 표기다.</b> 레거시가 그렇게 적어 두었고
// 실제 태그 3종이 <c>_InitalDatatable</c> 이다(LegacyTagCatalog.cs:184, 194, 196).
// "InitialDatatable" 만 보면 그 셋이 표가 아니라 환자 문맥으로 분류되어,
// 화면이 "환자 정보가 있어야 값이 나온다"는 <b>틀린 사유</b>를 말한다.
// 맞춤법을 고치는 것이 아니라 실재하는 표기를 받는 것이다.
if (tag.EndsWith("List", StringComparison.Ordinal)
|| tag.EndsWith("InitialDatatable", StringComparison.Ordinal)
|| tag.EndsWith("InitalDatatable", StringComparison.Ordinal))
{
return TagPreviewKind.Table;
}
// 싸인·서명·직인·로고 — 값은 <b>이미지 파일 경로</b>다. 환자 해석기가 경로를 얻어
// 이 단말에서 파일을 찾고(레거시 캐시 → 경로 자체), PictureBox 가 그린다.
// 파일서버 전송(ActiveX)은 옮기지 않았으므로 캐시가 없는 단말에서는 사유가 된다.
// 분류는 PatientContext 다 — Image 로 두면 "글자만 보여 준다"는 <b>틀린 사유</b>가 나간다.
if (tag.Contains("싸인", StringComparison.Ordinal)
|| tag.Contains("서명", StringComparison.Ordinal)
|| tag.Contains("직인", StringComparison.Ordinal)
|| tag.Contains("로고", StringComparison.Ordinal))
{
return TagPreviewKind.PatientContext;
}
return TagPreviewKind.PatientContext;
}
/// <summary>값을 만들 수 있는 태그인가</summary>
public static bool CanResolve(string tag)
=> KindOf(tag) is TagPreviewKind.Clock or TagPreviewKind.LoginUser;
/// <summary>못 만드는 이유를 사람 말로 — 만들 수 있는 태그면 빈 문자열</summary>
public static string ReasonText(string tag) => KindOf(tag) switch
{
TagPreviewKind.PatientContext =>
"환자·내원 정보가 있어야 값이 나오는 태그입니다. 서식생성기에는 환자 문맥이 없습니다.",
TagPreviewKind.Table =>
"값이 아니라 목록(표)을 돌려주는 태그입니다. 콤보를 채우는 용도라 한 줄로 보여 줄 값이 없습니다.",
TagPreviewKind.Image =>
"파일서버에서 내려받은 이미지 경로를 돌려주는 태그입니다. 여기서는 글자만 보여 줍니다.",
TagPreviewKind.Dead =>
"레거시에서 항상 빈 값을 돌려주는 태그입니다.",
TagPreviewKind.NotATag =>
"레거시 내부 함수 이름입니다. 태그로 쓰면 값이 나오지 않습니다.",
_ => string.Empty,
};
/// <summary>값 옆에 붙일 근거 한 줄</summary>
public static string SourceText(string tag) => KindOf(tag) switch
{
TagPreviewKind.Clock => "서버 시각 기준",
TagPreviewKind.LoginUser => "로그인 사용자 기준",
_ => string.Empty,
};
/// <summary>서버 시각에서 이 태그의 값을 만든다 — 시각 문자열은 yyyyMMddHHmm(12자리)</summary>
public static string FromClock(string tag, string minute12)
{
if (!ClockTags.TryGetValue(tag, out var format) || minute12.Length < 12)
{
return string.Empty;
}
var date = minute12[..8];
return format switch
{
ClockFormat.Date => $"{date[..4]}-{date[4..6]}-{date[6..8]}",
ClockFormat.DateTime => $"{date[..4]}-{date[4..6]}-{date[6..8]} {minute12[8..10]}:{minute12[10..12]}",
ClockFormat.DateTimeMask => $"{date[..4]}-{date[4..6]}-{date[6..8]}{minute12[8..10]}:{minute12[10..12]}",
// 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치
ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}",
ClockFormat.Year => date[..4],
ClockFormat.YearSuffixed => date[..4] + "년",
ClockFormat.Month => date[4..6],
ClockFormat.Day => date[6..8],
ClockFormat.Hour => minute12[8..10],
ClockFormat.Minute => minute12[10..12],
ClockFormat.Time => $"{minute12[8..10]}:{minute12[10..12]}",
_ => string.Empty,
};
}
/// <summary>로그인 사용자 정보에서 이 태그의 값을 만든다</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office)
=> FromUser(tag, id, name, mobile, office, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>9-인자판 — 부서 전화번호가 없던 시절의 호출부를 위해 위임</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office,
string license, string specialist, string job, string depName)
=> FromUser(tag, id, name, mobile, office, license, specialist, job, depName, string.Empty);
/// <summary>로그인 사용자 정보에서 — 면허·전문의·직종·부서·부서전화까지 받는 본판</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office,
string license, string specialist, string job, string depName, string depPhone)
{
if (!UserTags.TryGetValue(tag, out var field))
{
return string.Empty;
}
return field switch
{
UserField.Name => name,
UserField.Id => id,
// 레거시(bzDataInterface.vb:17930)는 빈 값 검사 없이 <b>항상</b> 결합한다 —
// 한쪽이 비면 "id." 나 ".이름" 이 그대로 나간다. 조건을 붙였더니 레거시와 갈렸다.
UserField.IdAndName => $"{id}.{name}",
UserField.MobilePhone => mobile,
UserField.OfficePhone => office,
UserField.License => license,
UserField.Specialist => specialist,
UserField.Job => job,
UserField.Department => depName,
// 레거시(:17101)는 NVL(DepOfcNam,DepKorNam)||'-'||UidNam — 개원일 미상 결정으로 한글명 고정.
// 레거시는 UidNam 으로 걸러 동명이인에서 남의 부서가 나올 수 있는데 여기는 세션 사용자 행이라 결정적이다.
UserField.DepartmentAndName => depName.Length > 0 || name.Length > 0 ? $"{depName}-{name}" : string.Empty,
UserField.DepPhone => depPhone,
_ => string.Empty,
};
}
private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, YearSuffixed, Month, Day, Hour, Minute, Time }
private enum UserField { Name, Id, IdAndName, MobilePhone, OfficePhone, License, Specialist, Job, Department, DepartmentAndName, DepPhone }
#endregion
}
+95
View File
@@ -46,6 +46,28 @@ public static class TagSearch
};
}
/// <summary>
/// 같은 값을 다른 형식으로 내는 태그들 — PAT_주민번호 → _Dash · _Blind.
/// 목업의 '관련 필드' 자리를 이것으로 채운다. 없는 개념을 흉내내는 대신
/// 지금 데이터로 계산되는 진짜 관계를 쓴다 — 마지막 밑줄 앞까지가 같으면 한 갈래다.
/// </summary>
public static List<string> VariantsOf(string tag, IEnumerable<string> all)
{
var cut = tag.LastIndexOf('_');
var stem = cut > 0 ? tag[..cut] : tag;
// 밑줄이 하나뿐이면 그건 접두어 경계다(PAT_이름) — 줄기가 'PAT' 이 되어 분류 전체가 변형으로 딸려 온다
if (stem.Length == 0 || !stem.Contains('_'))
{
return new List<string>();
}
return all
.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)
&& (string.Equals(t, stem, StringComparison.Ordinal)
|| t.StartsWith(stem + "_", StringComparison.Ordinal)))
.OrderBy(t => t, StringComparer.Ordinal)
.ToList();
}
/// <summary>
/// 분류 안의 소분류 — 두 번째 토큰. <c>PAT_주민번호_Dash</c> → "주민번호".
/// 같은 뿌리에서 갈라진 변형(_Dash, _Blind, _Refer)이 흩어지지 않고 붙어 보이게 한다.
@@ -106,10 +128,83 @@ public static class TagSearch
{
return true;
}
// 사람이 치는 말을 카탈로그 표기로 옮겨 한 번 더 본다.
// 이것 없이는 '환자명'·'날짜'·'사인' 같은 흔한 말이 <b>전부 0건</b>이었다(§Aliases).
foreach (var alias in AliasesFor(token))
{
if (tag.Contains(alias, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return IsChoseongQuery(token)
&& ToChoseong(tag).Contains(token, StringComparison.Ordinal);
}
/// <summary>
/// 사용자가 칠 말 → 카탈로그의 실제 표기.
///
/// <b>왜 필요한가.</b> 검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다.
/// 실제로 세어 본 결과 흔한 말이 전부 0건이었다 —
/// <c>환자명</c>(표기는 이름) · <c>날짜</c>(일자 21건) · <c>사인</c>(싸인 16건) ·
/// <c>주민등록번호</c>(주민번호 5건) · <c>오늘</c>(현재 11건) · <c>도장</c>(직인) ·
/// <c>신장</c>(키) · <c>남녀</c>(성별) · <c>성명</c>(이름).
/// 서명 태그 16종이 전부 레거시 오표기 <c>싸인</c> 이라, 의료진이 '사인'을 치면
/// "결과 없음"만 나오고 회복 경로가 없었다. <b>사용자가 못 찾은 이유는 접두어가 아니라 어휘였다.</b>
///
/// <b>태그마다 설명을 지어내는 것이 아니다.</b> 여기 있는 것은 <b>입력어 번역표</b>이고
/// 오른쪽 값은 전부 카탈로그에 실재하는 표기다. 없는 내용을 만들지 않는다.
///
/// 한 방향으로만 적어도 양방향으로 듣는다 — '이름'을 쳐도 원래 부분 일치로 걸린다.
/// </summary>
private static readonly Dictionary<string, string[]> Aliases = new(StringComparer.OrdinalIgnoreCase)
{
["환자명"] = new[] { "이름" },
["환자이름"] = new[] { "이름" },
["성명"] = new[] { "이름" },
["이름"] = new[] { "성명" },
["주민등록번호"] = new[] { "주민번호" },
["주번"] = new[] { "주민번호" },
["등록번호"] = new[] { "차트번호" },
["차번"] = new[] { "차트번호" },
["날짜"] = new[] { "일자" },
["날자"] = new[] { "일자" },
["오늘"] = new[] { "현재" },
["금일"] = new[] { "현재" },
["지금"] = new[] { "현재" },
["시각"] = new[] { "시간" },
["사인"] = new[] { "싸인" },
["서명"] = new[] { "싸인" },
["싸인"] = new[] { "서명" },
["도장"] = new[] { "직인" },
["인감"] = new[] { "직인" },
["신장"] = new[] { "키" },
["남녀"] = new[] { "성별" },
["성"] = new[] { "성별" },
["전화"] = new[] { "연락처", "전화" },
["폰"] = new[] { "연락처", "전화" },
["핸드폰"] = new[] { "휴대", "연락처" },
["휴대폰"] = new[] { "휴대", "연락처" },
["번호"] = new[] { "차트번호", "주민번호" },
["병원"] = new[] { "요양기관", "병원" },
["의사"] = new[] { "의사", "담당" },
["과"] = new[] { "진료과" },
["주소"] = new[] { "주소", "도로명" },
["나이"] = new[] { "나이", "연령" },
["연령"] = new[] { "나이" },
["생일"] = new[] { "생년월일" },
};
private static string[] AliasesFor(string token)
=> Aliases.TryGetValue(token, out var found) ? found : Array.Empty<string>();
/// <summary>이 말이 카탈로그 표기와 다른가 — 화면이 "'사인'은 '싸인'으로 찾습니다"를 알릴 때 쓴다</summary>
public static string? AliasHintFor(string query)
{
var token = query.Trim();
return Aliases.TryGetValue(token, out var found) && found.Length > 0 ? found[0] : null;
}
/// <summary>공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)</summary>
public static bool MatchesAll(string tag, IReadOnlyList<string> tokens)
{
+146
View File
@@ -0,0 +1,146 @@
namespace SheetMe.Core.Catalog;
/// <summary>내원 구간에서 어느 끝을 기준으로 삼을지 — 레거시 EN_FirstorLast</summary>
public enum VisitEdge
{
/// <summary>마지막 시점(기본) — 퇴원·자격 종료 쪽</summary>
Last,
/// <summary>첫 시점 — 접수·자격 시작 쪽</summary>
First,
}
/// <summary>적용일시 판정에 필요한 내원 정보 — 전부 YYYYMMDDHHMM / YYYYMMDD 문자열이다</summary>
/// <param name="Inpatient">입원인가(ComPatTyp = 'I')</param>
/// <param name="AcceptedAt">ComAcpDtm — 접수일시(12자리)</param>
/// <param name="LeftAt">ComLevDtm — 퇴원일시(12자리). 재원 중이면 비거나 미래값</param>
/// <param name="CoverageFrom">CoiStrDte — 자격 시작일(8자리)</param>
/// <param name="CoverageTo">CoiEndDte — 자격 종료일(8자리). 재원 중이면 29991231</param>
public readonly record struct VisitWindow(
bool Inpatient, string AcceptedAt, string LeftAt, string CoverageFrom, string CoverageTo);
/// <summary>
/// 내원의 <b>적용일시</b>(AdpDtm)를 정한다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> +
/// <c>dtCommonLib.GetComAdpDtm</c> 의 판정을 옮긴 것.
///
/// <b>왜 이것이 중요한가.</b> 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 —
/// 진료과·담당의(<c>P_CodInf</c>), 보험 자격(<c>P_CoiInf</c>), 병동·병실(<c>P_CowInf</c>) 모두
/// <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다. 시각이 한 칸 어긋나면 조인이 0행이 되고
/// <b>태그가 전부 빈 값</b>이 된다. 값이 틀리는 것이 아니라 통째로 안 나온다.
///
/// <b>순수 함수로 두는 이유.</b> 갈래가 여섯이고(외래/입원×First/Last, 재원 중, 호출자 지정)
/// 각각 경계 조건이 다르다. 저장소 안에 묻어 두면 실DB 없이는 한 갈래도 확인할 수 없다.
///
/// 시각 비교는 <b>문자열 사전순</b>으로 한다 — 레거시가 그렇게 하고, 고정폭 YYYYMMDDHHMM 은
/// 사전순이 곧 시간순이다. DateTime 으로 바꾸면 잘못된 값(빈 문자열·공백)에서 예외가 난다.
/// </summary>
public static class VisitMoment
{
#region Member Fields
/// <summary>자격 종료일이 이 값이면 아직 진행 중이다(레거시 관행)</summary>
private const string OpenEnded = "29991231";
#endregion
#region Methods
/// <summary>
/// 호출자가 시각을 주지 않았을 때 — 내원 자체에서 정한다
/// (<c>dtCommonLib.GetComAdpDtm</c>, :3289-3346).
/// </summary>
/// <param name="serverNow">DB 서버 시각 12자리. 단말 시계를 쓰지 않는다</param>
public static string Decide(VisitWindow visit, VisitEdge edge, string serverNow)
{
// 외래는 접수일시가 곧 적용일시다 — 자격 구간을 보지 않는다(:3344)
if (!visit.Inpatient)
{
return Clean(visit.AcceptedAt);
}
if (edge == VisitEdge.First)
{
// 자격 시작과 접수 중 <b>늦은</b> 쪽 — 자격이 접수 전에 시작했어도 내원 밖은 의미가 없다(:3334-3338)
return Later(Day(visit.CoverageFrom, "0000"), Clean(visit.AcceptedAt));
}
if (Clean(visit.CoverageTo) == OpenEnded)
{
// 재원 중 — 지금이 기준이다. 단 입원 전환이 미래로 잡혀 있으면 접수 시각을 쓴다(:3310-3316)
var now = Clean(serverNow);
return Compare(now, Clean(visit.AcceptedAt)) <= 0 ? Clean(visit.AcceptedAt) : now;
}
// 자격 종료와 퇴원 중 <b>이른</b> 쪽 — 둘 중 먼저 끝난 것이 실제 끝이다(:3320-3328)
return Earlier(Day(visit.CoverageTo, "2359"), Clean(visit.LeftAt));
}
/// <summary>
/// 호출자가 시각을 준 경우 — 내원 구간 안으로 <b>가둔다</b>
/// (<c>bzPatientInfo.vb:441-511</c>).
///
/// 가두지 않으면 내원 밖 시각으로 자격·병실을 조회해 0행이 나온다.
/// 해석할 수 없는 값이면 빈 문자열을 돌려준다 — 호출부가 <see cref="Decide"/> 로 넘어가야 한다.
/// </summary>
public static string Clamp(string requested, VisitWindow visit, VisitEdge edge)
{
var value = Clean(requested);
var accepted = Clean(visit.AcceptedAt);
var left = Clean(visit.LeftAt);
// 8자리(일자) — 그 날의 어느 끝을 쓸지 edge 가 정하고, 내원 경계가 더 안쪽이면 경계를 쓴다
if (value.Length == 8)
{
if (edge == VisitEdge.Last)
{
var candidate = value + "2359";
return left.Length >= 12 && Compare(left, candidate) <= 0 ? left : candidate;
}
var first = value + "0000";
return accepted.Length >= 12 && Compare(accepted, first) >= 0 ? accepted : first;
}
// 12자리 이상(일시) — 접수 전이면 접수로, 퇴원 후면 퇴원으로 당긴다
if (value.Length >= 12)
{
var moment = value[..12];
if (accepted.Length >= 12 && Compare(moment, accepted) < 0)
{
return accepted;
}
if (left.Length >= 12 && Compare(moment, left) > 0)
{
return left;
}
return moment;
}
// 해석할 수 없다 — 레거시도 빈 문자열로 둔다(:505, :511)
return string.Empty;
}
/// <summary>
/// 호출자 지정과 자동 판정을 한 번에 — 화면에서 쓰는 진입점.
/// 지정값이 해석되지 않으면 자동 판정으로 떨어진다.
/// </summary>
public static string Resolve(string? requested, VisitWindow visit, VisitEdge edge, string serverNow)
{
var clamped = Clamp(requested ?? string.Empty, visit, edge);
return clamped.Length > 0 ? clamped : Decide(visit, edge, serverNow);
}
/// <summary>8자리 일자에 시각을 붙인다 — 일자가 없으면 빈 문자열</summary>
private static string Day(string date, string time)
{
var value = Clean(date);
return value.Length >= 8 ? value[..8] + time : string.Empty;
}
/// <summary>둘 중 늦은 쪽. 한쪽이 비면 다른 쪽</summary>
private static string Later(string a, string b)
=> a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) >= 0 ? a : b;
/// <summary>둘 중 이른 쪽. 한쪽이 비면 다른 쪽</summary>
private static string Earlier(string a, string b)
=> a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) <= 0 ? a : b;
private static int Compare(string a, string b) => string.CompareOrdinal(a, b);
/// <summary>CHAR 고정폭 잔여 공백을 흡수한다 — 이 컬럼들은 전부 고정폭이다</summary>
private static string Clean(string? value) => (value ?? string.Empty).Trim();
#endregion
}
@@ -14,6 +14,17 @@ public static class LegacyTypeCatalog
/// <summary>페이지 루트 클래스 단축명</summary>
public const string HostClassName = "MDesignerHost";
/// <summary>
/// 데이터소스의 <b>중립</b> 타입명 — 레거시 클래스명은 <c>MDataTable</c> 이고 여기서 이 이름으로 바뀐다.
///
/// <b>상수로 두는 이유.</b> 두 이름이 공존해서 실제로 사고가 났다.
/// 배선 실행기가 레거시 이름(<c>MDataTable</c>)으로 컨트롤을 찾고 있었는데,
/// 서식을 XML 에서 읽으면 그 시점에 이미 중립 이름으로 바뀌어 있어 <b>한 개도 못 찾았다</b> —
/// 실사용 서식의 데이터소스 배선이 전부 "이 서식에 없습니다"로 죽었다.
/// 문자열을 각자 적는 한 같은 일이 또 난다.
/// </summary>
public const string DataTableType = "DataTable";
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
public const string HostAqn =
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
@@ -42,7 +53,7 @@ public static class LegacyTypeCatalog
["MPictureBox"] = "PictureBox",
["MCalcBox"] = "CalcBox",
["MButton"] = "Button",
["MDataTable"] = "DataTable",
["MDataTable"] = DataTableType,
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
["Spread"] = "Spread",
};
+14
View File
@@ -43,6 +43,20 @@ public sealed class DataConfig
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
/// </summary>
public string DevUidCod { get; set; } = string.Empty;
/// <summary>
/// 싸인·직인 이미지의 <b>서버 경로 → UNC 공유</b> 대응 — <c>"서버접두=UNC접두"</c> 목록.
/// 예: <c>D:\MsystechHIS=\\192.168.100.222\MsystechHIS</c>
///
/// <b>왜 설정인가.</b> DB 는 이미지 위치를 파일서버의 <b>로컬 절대경로</b>로 들고 있고
/// (실측: <c>D:\MsystechHIS\{사이트}\…</c>), 레거시는 그 경로를 ActiveX OCX 데몬
/// (기본 포트 2002)에 넘겨 받아 온다. 그 프로토콜은 재현할 수 없으므로 우리는 같은 파일을
/// <b>공유 폴더로 직접</b> 읽는다 — 접두 대응은 병원마다 다르니 코드가 아니라 설정이다.
///
/// 비어 있으면(기본) 매핑을 시도하지 않는다 — 지금까지와 같이 로컬 캐시만 본다.
/// <b>자격증명은 담지 않는다</b>: 공유 접근 권한은 단말 로그인 계정으로 해결한다.
/// </summary>
public string[] SignatureShareMap { get; set; } = Array.Empty<string>();
#endregion
#region Methods
@@ -103,6 +103,31 @@ public static class ServerInfoReader
}
}
/// <summary>
/// HIS 설치 경로 — ini 의 <c>[MSYSTECHHIS\Install] Path</c>(레거시
/// <c>GetInstallPathFromServerInfoFile</c>, clsCommonLib.vb:271-287 과 같은 값).
/// 서명 이미지 캐시 폴더의 뿌리다. 이 값은 암호화되어 있지 않다(경로일 뿐).
/// ini 가 없거나 값이 비면 null — 레거시는 여기서 오류창을 띄우고 빈 문자열을 돌려줘
/// 폴더가 루트 상대경로("\Tmp\...")가 되어 버린다. 그 결함은 옮기지 않는다.
/// </summary>
public static string? InstallPath(string? iniPath = null)
{
var path = ResolveIniPath(iniPath);
if (path is null)
{
return null;
}
try
{
var value = ReadValue(File.ReadAllLines(path), @"MSYSTECHHIS\Install", "Path");
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
catch
{
return null;
}
}
/// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
private static string? ResolveIniPath(string? iniPath)
{
+128
View File
@@ -0,0 +1,128 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 상병(진단) 조회 — 레거시 <c>Okd_상병쿼리</c>(bzDataInterface.vb:14160-14623)의 이식.
///
/// <b>이 DB(SRCH)가 타는 Case Else 갈래만 옮겼다.</b> 원문은 병원 코드로 여섯 갈래
/// (KIMEYE·NYJB·SYBS·HIMCHAN_CW·SPHH·Else)로 갈리는데, 그 다섯은 SELECT 목록·이름 결합이
/// 제각각이라 각각 별도 이식이다. 그 병원에서는 태그가 <b>미이식이라고 말한다</b> —
/// Else 갈래로 얼버무리면 그 병원 서식에 다른 모양의 상병명이 조용히 찍힌다.
///
/// <b>SQL 은 원문에서 기계 추출했다</b>(Select Case 를 Case Else 경로로 시뮬레이션) —
/// 손으로 다시 치면 공백·결합 규칙이 달라진다. 구조는:
/// 블록1(M_KcdMst 와 맞는 상병) UNION 블록2(마스터에 없는 코드 — LEFT JOIN 후 KcdCod IS NULL)
/// 각 블록에 주/부 필터가 붙고 블록마다 ROW_NUMBER=1 로 접은 뒤 OkdDspSeq 순으로 정렬한다.
///
/// 값은 이 저장소가 만들지 않는다 — 행 목록만 돌려주고 결합(가로/세로·확진/의증 접미)은
/// 해석기가 한다. 태그 8종이 같은 결과셋을 다르게 자를 뿐이기 때문이다.
/// </summary>
public sealed class DiagnosisStore
{
#region Member Fields
/// <summary>주/부 구분 — 레거시 psOkdTyp(T=전체, M=주상병, S=부상병)</summary>
public enum Scope { All, Main, Sub }
private readonly string connectionString;
/// <summary>원문 그대로의 쿼리 틀 — {TYPE} 자리에 주/부 필터가 들어간다</summary>
private const string Template = @"
SELECT *
FROM (
SELECT *
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdElcCod || '-' || TRIM(KcdSeq) AS KcdSeqCod, OkdPoaTyp
, OkdDtrNam
, ROW_NUMBER() OVER(PARTITION BY KcdCod ORDER BY KcdStrDte DESC) AS OKD_RN
FROM O_OkdInf
INNER JOIN P_ComInf
ON OkdComNum = ComNum
INNER JOIN M_KcdMst
ON KcdCod = TRIM(OkdKcdCod)
AND TRIM(OkdKcdSeq) = KcdSeq
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
WHERE OkdComNum = :piComNum
AND OkdStt = 'E'
{TYPE}
) OKD
WHERE OKD_RN = 1
UNION
SELECT *
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
, KCD.KcdCod, KCD.KcdElcCod, KCD.KcdKorNam, KCD.KcdEngNam, KCD.KcdElcCod || '-' || TRIM(KCD.KcdSeq) AS KcdSeqCod, OkdPoaTyp
, OkdDtrNam
, ROW_NUMBER() OVER(PARTITION BY KCD.KcdCod ORDER BY KCD.KcdStrDte DESC) AS OKD_RN
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdSeq, OkdPoaTyp
, OkdDtrNam
FROM O_OkdInf
INNER JOIN P_ComInf
ON OkdComNum = ComNum
LEFT JOIN M_KcdMst
ON KcdCod = TRIM(OkdKcdCod)
AND TRIM(OkdKcdSeq) = KcdSeq
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
WHERE OkdComNum = :piComNum
AND OkdStt = 'E'
) OKD
INNER JOIN M_KcdMst KCD
ON KCD.KcdCod = TRIM(OkdKcdCod)
AND OkdKcdSeq = KCD.KcdSeq
WHERE 1=1
AND (OKD.KcdCod IS NULL OR OKD.KcdCod = ' ')
{TYPE}
) OKD
WHERE OKD_RN = 1
)
ORDER BY OkdDspSeq
";
#endregion
#region Constructors
public DiagnosisStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>이 내원의 상병 행들 — 표시 순서(OkdDspSeq)대로. 없으면 빈 목록</summary>
public List<Dictionary<string, string>> Diagnoses(decimal comNum, Scope scope = Scope.All)
{
var filter = scope switch
{
Scope.Main => " AND OkdDspSeq = '1' ",
Scope.Sub => " AND OkdDspSeq <> '1' ",
_ => string.Empty,
};
var sql = Template.Replace("{TYPE}", filter);
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
command.Parameters.Add(new OracleParameter("piComNum", comNum));
var rows = new List<Dictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i)
? string.Empty
: reader.GetValue(i).ToString()!.Trim();
}
rows.Add(row);
}
return rows;
}
#endregion
}
@@ -214,6 +214,17 @@ public sealed class OracleLegacyFormStore
}
/// <summary>특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null</summary>
/// <summary>특정 버전의 원문 XML — 이력 행이 옛 내용을 담았는지 확인하는 데 쓴다</summary>
public string? LoadDesignRawByKey(string shtCod, decimal sdgKey)
{
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
command.Parameters.Add(new OracleParameter("k", sdgKey));
return command.ExecuteScalar() as string;
}
public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey)
{
using var connection = Open();
@@ -286,6 +297,134 @@ public sealed class OracleLegacyFormStore
/// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션.
/// 반환: 저장된 SdgKey.
/// </summary>
/// <summary>
/// 지금 쓰이는 버전을 <b>제자리에서</b> 정정한다 — 레거시 <c>TK_MODIFY</c>(3-인자 오버로드).
///
/// <b>저장과 이력 방향이 반대다.</b> 저장은 옛 행을 <c>SdgDelYon='Y'</c> 로 내리고 새 행이 활성이 된다.
/// 수정은 <b>활성 행이 제자리에서 갱신되고, 옛 XML 을 담은 새 행이 이력</b>으로 들어간다.
/// 그래서 <c>SdgKey</c> 가 바뀌지 않는다 — EMR 이 참조하던 키가 그대로 유지된다.
///
/// <b><c>E_SctMst</c> 를 건드리지 않는다.</b> 레거시가 그 부분을 주석 처리해 두었고
/// (<c>bzSaveSheetDesignNControlInfo.vb:170-174</c>), 그것이 "글자만 수정 가능"이라는 경고문의
/// 기술적 근거다. SdgKey 가 그대로라 기존 매핑 행은 유효하지만 <b>새 컨트롤은 매핑 행이 안 생긴다</b>.
/// 호출부가 <see cref="NewControlNames"/> 로 먼저 막는다.
///
/// <b>레거시 버그 둘은 복제하지 않는다.</b>
/// ① 감사 컬럼이 뒤집혀 있었다 — 활성 행에는 아무것도 안 쓰고
/// <b>옛 디자인을 담은 이력 행</b>이 현재 사용자·시각을 받았다(<c>:162-163</c>).
/// 그러면 이력 패널이 "누가 언제 이 버전을 만들었는가"를 거꾸로 보여 준다.
/// 여기서는 활성 행에 기록하고, 이력 행은 원래 값을 지킨다.
/// ② <c>Rows(0)</c> 을 개수 검사보다 먼저 읽어 활성 행이 없으면 예외였다(<c>:150</c>).
/// 여기서는 먼저 확인하고 뜻이 통하는 오류를 낸다.
/// </summary>
public decimal ModifyDesign(FormDocument document, string uid)
{
if (string.IsNullOrWhiteSpace(document.FormId))
{
throw new InvalidOperationException("서식 코드(FormId)가 없습니다.");
}
var mergedXml = serializer.Write(document);
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
var now = ServerClock.Read(connection, transaction).Minute12;
var activeSdgKey = ResolveActiveSdgKey(connection, transaction, document.FormId);
if (activeSdgKey <= 0)
{
throw new InvalidOperationException(
$"서식 [{document.FormId}] 에 지금 쓰이는 디자인이 없습니다. 수정 대신 저장을 쓰세요.");
}
// 옛 디자인과 그 감사 값을 먼저 떠 둔다 — 이력 행이 그 값을 그대로 가져가야
// "그 버전을 누가 언제 만들었는지"가 보존된다.
string oldXml = string.Empty, oldUid = uid, oldStamp = now;
using (var read = connection.CreateCommand())
{
read.Transaction = transaction;
read.BindByName = true;
read.CommandText =
"SELECT SdgDesign, NVL(SdgUidCod,' '), NVL(SdgUpdDtm,' ') FROM E_SdgMst WHERE SdgKey = :k";
read.Parameters.Add(new OracleParameter("k", activeSdgKey));
using var reader = read.ExecuteReader();
if (reader.Read())
{
oldXml = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
oldUid = reader.GetString(1).Trim();
oldStamp = reader.GetString(2).Trim();
}
}
NonQuery(connection, transaction,
"UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k",
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
new OracleParameter("u", uid),
new OracleParameter("t", now),
new OracleParameter("k", activeSdgKey));
var historyKey = Convert.ToDecimal(Scalar(connection, transaction,
"SELECT NVL(MAX(SdgKey),0)+1 FROM E_SdgMst"));
NonQuery(connection, transaction,
"INSERT INTO E_SdgMst (SdgKey, SdgShtCod, SdgDesign, SdgUidCod, SdgUpdDtm,"
+ " SdgDelYon, SdgDelUid, SdgDelDtm)"
+ " VALUES (:k, :s, :d, :u, :t, 'Y', '', '')",
new OracleParameter("k", historyKey),
new OracleParameter("s", document.FormId),
new OracleParameter("d", OracleDbType.Clob) { Value = oldXml },
new OracleParameter("u", oldUid.Length > 0 ? oldUid : uid),
new OracleParameter("t", oldStamp.Length > 0 ? oldStamp : now));
transaction.Commit();
return activeSdgKey;
}
catch
{
transaction.Rollback();
throw;
}
}
/// <summary>
/// 이 문서에 있는데 지금 활성 버전의 <c>E_SctMst</c> 에는 없는 컨트롤 이름들.
///
/// 수정은 <c>E_SctMst</c> 를 안 쓰므로 이 이름들은 <b>매핑 행 없이</b> 저장되고
/// EMR 에서 조용히 값이 안 채워진다. 그래서 호출부가 수정을 막는 근거로 쓴다.
/// 판정 근거를 <see cref="SctMstXmlWalker"/> 로 통일한다 — 저장이 행을 만드는 규칙과 같은 것이라
/// "행이 원래 안 생기는 종류"를 새 컨트롤로 오인하지 않는다.
/// </summary>
public List<string> NewControlNames(FormDocument document)
{
var names = new List<string>();
using var connection = Open();
var activeSdgKey = ResolveActiveSdgKey(connection, null, document.FormId);
if (activeSdgKey <= 0)
{
return names;
}
var existing = new HashSet<string>(StringComparer.Ordinal);
using (var query = connection.CreateCommand())
{
query.BindByName = true;
query.CommandText = "SELECT SctObjNam FROM E_SctMst WHERE SctSdgKey = :k";
query.Parameters.Add(new OracleParameter("k", activeSdgKey));
using var reader = query.ExecuteReader();
while (reader.Read())
{
existing.Add(reader.GetString(0));
}
}
foreach (var row in SctMstXmlWalker.Walk(serializer.Write(document)))
{
if (!string.Equals(row.ObjType, SpreadItemType, StringComparison.OrdinalIgnoreCase)
&& !existing.Contains(row.ObjName) && !names.Contains(row.ObjName))
{
names.Add(row.ObjName);
}
}
return names;
}
public decimal SaveDesign(FormDocument document, string uid)
{
if (string.IsNullOrWhiteSpace(document.FormId))
@@ -0,0 +1,423 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다.
/// 각 행은 컬럼명 → 값 사전이다. 태그가 <c>&lt;&lt;Patient.PatInfDR.item("PatNam")&gt;&gt;</c> 처럼
/// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다.
/// </summary>
public sealed record PatientContext(
decimal ComNum,
string AdpDtm,
IReadOnlyDictionary<string, string> PatInf,
IReadOnlyDictionary<string, string> ComInf,
IReadOnlyDictionary<string, string> CodInf,
IReadOnlyDictionary<string, string> CoiInf,
IReadOnlyDictionary<string, string> CowInf)
{
/// <summary>행 하나에서 컬럼 값 — 없으면 빈 문자열</summary>
public static string Value(IReadOnlyDictionary<string, string> row, string column)
=> row.TryGetValue(column, out var value) ? value : string.Empty;
}
/// <summary>
/// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> 대응.
///
/// <b>다섯 행이 전부 적용일시를 키로 쓴다.</b> 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf),
/// 병동·병실(P_CowInf) 은 <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다 —
/// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다.
/// 그래서 시각 판정은 <see cref="Core.Catalog.VisitMoment"/> 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다.
///
/// <b>레거시와 다른 점</b>: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다
/// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 <b>한 번에 다섯 번</b> 읽고 들고 있는다 —
/// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.
///
/// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다.
/// </summary>
public sealed class PatientContextStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public PatientContextStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>이 내원의 문맥 — 내원 자체가 없으면 null</summary>
public PatientContext? Load(decimal comNum, Core.Catalog.VisitEdge edge = Core.Catalog.VisitEdge.Last)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var comInf = Row(connection, "SELECT * FROM P_ComInf WHERE ComNum = :k", ("k", comNum));
if (comInf.Count == 0)
{
return null;
}
var window = new Core.Catalog.VisitWindow(
PatientContext.Value(comInf, "COMPATTYP") == "I",
PatientContext.Value(comInf, "COMACPDTM"),
PatientContext.Value(comInf, "COMLEVDTM"),
string.Empty, string.Empty);
// 자격 구간은 P_CoiInf 가 갖고 있다 — 시각을 정하려면 먼저 읽어야 하고,
// 그 읽기 자체는 시각을 쓰지 않는다(MtiSeq 0 의 최신 행).
var coiSeed = Row(connection,
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
+ " ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum));
window = window with
{
CoverageFrom = PatientContext.Value(coiSeed, "COISTRDTE"),
CoverageTo = PatientContext.Value(coiSeed, "COIENDDTE"),
};
var adpDtm = Core.Catalog.VisitMoment.Decide(window, edge, ServerClock.Read(connection, null).Minute12);
var chtNum = PatientContext.Value(comInf, "COMCHTNUM");
var patInf = Row(connection, "SELECT * FROM P_PatInf WHERE TRIM(PatChtNum) = :c", ("c", chtNum.Trim()));
var codInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
("k", comNum), ("t", adpDtm));
var coiInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
+ " AND :d BETWEEN CoiStrDte AND CoiEndDte ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1",
("k", comNum), ("d", Day(adpDtm)));
var cowInf = Row(connection,
"SELECT * FROM (SELECT * FROM P_CowInf WHERE CowComNum = :k"
+ " AND :t BETWEEN CowStrDtm AND CowEndDtm ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
("k", comNum), ("t", adpDtm));
return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf);
}
/// <summary>
/// 특정 시점의 진료과 코드 — 레거시 <c>GetCodInfDT(comNum, moment, 1)</c> 의 첫 행 대응.
/// 입원과(접수 시점)·퇴원과(퇴원/현재 시점) 태그가 문맥의 적용일시와 <b>다른 시점</b>을
/// 물을 때 쓴다. 원문 GetCodInfDT 는 ORDER BY 가 없어 임의 행이었다 — 문맥 로드와
/// 같은 규칙(시작일시 최신 1행)으로 고정한다. moment 가 비면 서버 현재시각이다.
/// </summary>
public string DepartmentCodeAt(decimal comNum, string? moment)
=> PatientContext.Value(CodInfAt(comNum, moment), "CodDepCod");
/// <summary>특정 시점의 진료 행(진료과 코드 + 담당의 코드) — <see cref="DepartmentCodeAt"/> 의 원형</summary>
public IReadOnlyDictionary<string, string> CodInfAt(decimal comNum, string? moment)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var at = (moment ?? string.Empty).Trim();
return Row(connection,
"SELECT CodDepCod, CodDtrCod FROM (SELECT CodDepCod, CodDtrCod FROM P_CodInf"
+ " WHERE CodComNum = :k AND CodMtiSeq = 0"
+ (at.Length >= 12
? " AND :t BETWEEN CodStrDtm AND CodEndDtm"
: " AND TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI') BETWEEN CodStrDtm AND CodEndDtm")
+ " ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
at.Length >= 12
? new (string, object)[] { ("k", comNum), ("t", at[..12]) }
: new (string, object)[] { ("k", comNum) });
}
/// <summary>
/// 특정 일자에 유효한 사용자 이름(M_UidMst.UidNam) — 입원의사·퇴원의사가 쓴다
/// (bzDataInterface.vb:6520-6527). 유효기간이 겹치는 이중 행은 정상 데이터가 아니지만
/// 원문의 Rows(0) 대신 한 행으로 고정한다.
/// </summary>
public string UserNameAt(string uidCod, string day)
{
if (uidCod.Trim().Length == 0 || day.Trim().Length < 8)
{
return string.Empty;
}
using var connection = new OracleConnection(connectionString);
connection.Open();
var row = Row(connection,
"SELECT UidNam FROM (SELECT UidNam FROM M_UidMst WHERE UidCod = :u"
+ " AND :d BETWEEN UidStrDte AND UidEndDte ORDER BY UidStrDte DESC) WHERE ROWNUM = 1",
("u", uidCod.Trim()), ("d", day.Trim()[..8]));
return PatientContext.Value(row, "UidNam").Trim();
}
/// <summary>
/// 협진 목록(bzDataInterface.vb:6360-6494) — 회신 완료(CstSttFlg='G')된 협진의
/// 회신 과(+의사). 원문 그대로: DISTINCT + 시작일 순 + 앞에서 과는 2건, 과·의사는 3건.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> Consults(decimal comNum, bool withDoctor)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = withDoctor
? "SELECT * FROM (SELECT DISTINCT UidCod, UidNam, DepCod, DepKorNam, CstStrDte FROM O_CstInf"
+ " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ " INNER JOIN M_UidMst ON CstRplDtr = UidCod AND CstStrDte BETWEEN UidStrDte AND UidEndDte"
+ " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 3"
: "SELECT * FROM (SELECT DISTINCT DepCod, DepKorNam, CstStrDte FROM O_CstInf"
+ " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 2";
command.Parameters.Add(new OracleParameter("m", comNum));
var rows = new List<IReadOnlyDictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
}
rows.Add(row);
}
return rows;
}
/// <summary>
/// 퇴원과 조회(bzDataInterface.vb:6162-6231) — 내원의 퇴원일시(또는 접수일시)가
/// 유효기간 안인 진료과 행. 원문이 P_ComInf 에 M_DepMst 를 <b>날짜 조건만으로</b> 조인하는
/// 특이한 모양이라 그대로 옮겼다. anchorColumn 은 ComLevDtm 또는 ComAcpDtm.
/// </summary>
public IReadOnlyDictionary<string, string> DischargeDepartment(decimal comNum, string depCod, string anchorColumn)
{
if (anchorColumn is not ("ComLevDtm" or "ComAcpDtm"))
{
throw new ArgumentOutOfRangeException(nameof(anchorColumn));
}
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT DepOfcNam, DepKorNam FROM P_ComInf"
+ " INNER JOIN M_DepMst ON DepCod = :d"
+ $" AND {anchorColumn} BETWEEN DepStrDte AND DepEndDte || '2359'"
+ " WHERE ComNum = :k) WHERE ROWNUM <= 1",
("d", depCod), ("k", comNum));
}
/// <summary>
/// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
/// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
/// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다.
/// </summary>
private static Dictionary<string, string> Row(OracleConnection connection, string sql,
params (string Name, object Value)[] parameters)
{
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in parameters)
{
command.Parameters.Add(new OracleParameter(name, value));
}
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return result;
}
for (var i = 0; i < reader.FieldCount; i++)
{
// CHAR 고정폭 잔여 공백을 여기서 한 번 걷는다 — 태그마다 걷으면 빠뜨린다
result[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
}
return result;
}
private static string Day(string stamp) => stamp.Length >= 8 ? stamp[..8] : stamp;
/// <summary>
/// 이 내원의 담당의 — <c>OCM_담당의사</c> 계열이 쓴다. 없으면 빈 사전.
///
/// <b>레거시와 의도적으로 다른 곳이 한 군데 있다.</b>
/// 원본 <c>GetCodInfDT(..., With_Join)</c> 는 <c>CodComNum</c> + <c>CodMtiSeq='0'</c> 으로만 거르고
/// <b>ORDER BY 가 없는데</b> 호출부는 <c>Rows(0)</c> 을 집는다.
/// 이 DB 에서 세어 보니 그런 내원이 <b>25,488건</b>이고 최대 <b>41행</b>이다 —
/// 즉 레거시는 그 내원들의 담당의를 확정적으로 찍지 못한다. 재현할 대상이 없으므로
/// "그대로 옮긴다"가 성립하지 않는다.
///
/// 그래서 적용일시로 거르고 최신순 1행을 집는다 — 문맥의 <c>CodInf</c> 와 같은 규칙이다.
/// 레거시도 이 시각을 <b>넘기기는 한다</b>(마스터 유효기간 조인에만 쓸 뿐이다).
/// 근거는 <c>docs/TAG-PORTABLE-48.md</c>.
///
/// 담당의 조회는 레거시 <c>GetUidMst</c>(clsCommonLib.vb:10615)를 그대로 옮긴다 —
/// 유효기간으로 먼저 찾고, <b>0행이면 코드만으로 다시 찾는다.</b>
/// 그 폴백을 빼면 유효기간이 지난 의사가 통째로 빈칸이 된다.
/// </summary>
/// <param name="moment">진료과 행을 고를 적용일시 12자리</param>
/// <param name="doctorDay">담당의 유효기간을 볼 날짜 8자리. 비면 <paramref name="moment"/> 의 앞 8자리</param>
/// <param name="codeColumn">진료과 행에서 의사 코드를 집을 컬럼 — 담당의 CodDtrCod, 주치의(전공의) CodStfDtr</param>
public IReadOnlyDictionary<string, string> Doctor(decimal comNum, string moment,
string? doctorDay = null, string codeColumn = "CodDtrCod")
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var cod = Row(connection,
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
("k", comNum), ("t", moment));
var code = PatientContext.Value(cod, codeColumn);
if (code.Length == 0)
{
return cod;
}
// 레거시는 입원이면 CodEndDtm 의 날짜로, 외래면 접수일자로 본다 — 호출부가 정해 넘긴다
var day = Day((doctorDay ?? string.Empty).Trim().Length > 0 ? doctorDay!.Trim() : moment);
var uid = Row(connection,
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c"
+ " AND :d BETWEEN UidStrDte AND UidEndDte) WHERE ROWNUM = 1",
("c", code), ("d", day));
return uid.Count > 0
? uid
// 폴백 — 유효기간 밖이어도 그 코드의 행을 쓴다(레거시 :10641-10644)
: Row(connection, "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c) WHERE ROWNUM = 1",
("c", code));
}
/// <summary>
/// 진료과 마스터 한 행 — <c>OCM_진료과</c>(bzDataInterface.vb:5437-5495)의 조회.
/// <c>WHERE DepCod = :d AND :day BETWEEN DepStrDte AND DepEndDte</c>.
///
/// 유효기간 판정 일자는 레거시가 <b>입원·외래 무관하게 접수일자</b>를 쓴다(:5445 sAcpDte).
/// 원본에는 ORDER BY 가 없고 Rows(0) 을 집는다 — 담당의 때와 같은 이유로
/// 최신순 1행으로 고정한다(기간이 겹친 이력에서 비결정적이 되는 것을 막는다).
/// </summary>
public IReadOnlyDictionary<string, string> Department(string? depCod, string? day)
{
var code = (depCod ?? string.Empty).Trim();
var date = (day ?? string.Empty).Trim();
if (code.Length == 0 || date.Length < 8)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT * FROM M_DepMst WHERE TRIM(DepCod) = :d"
+ " AND :y BETWEEN DepStrDte AND DepEndDte ORDER BY DepStrDte DESC) WHERE ROWNUM = 1",
("d", code), ("y", date[..8]));
}
/// <summary>
/// 요양기관 행 — <c>ETC_요양기관명칭_*</c> 12종(bzDataInterface.vb:15656-16709)의 공통 조회.
/// <c>M_DepMst ⨝ M_HspMst(DepHspCod=HspCod)</c> 에서 병원 컬럼들을 뽑는다.
/// 진료과 코드로 병원을 찾는 구조다 — 다부지(병원 코드가 다른 진료과) 구성 때문으로 보인다.
///
/// 레거시는 태그마다 이 조인을 <b>따로</b> 돌린다(12종 = 왕복 12회).
/// 컬럼만 다르므로 한 번에 다 뽑아 행으로 돌려준다 — 값 선택은 해석기가 한다.
/// 컬럼 9개는 전부 각 태그 본문의 SELECT 에서 확인한 것이다.
/// </summary>
public IReadOnlyDictionary<string, string> HospitalRow(string? depCod, string? day)
{
var code = (depCod ?? string.Empty).Trim();
var date = (day ?? string.Empty).Trim();
if (code.Length == 0 || date.Length < 8)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
using var connection = new OracleConnection(connectionString);
connection.Open();
// HspStrDte(개원일)는 퇴원 계열 태그의 분기 재료(HspCfg.HspStrDte 대응),
// ServerToday 는 "오늘" 폴백·재원일수 계산용 서버 날짜 — 단말 시계를 쓰지 않는다
return Row(connection,
"SELECT * FROM (SELECT HspInsNam, HspOwnNam, HspTelNum, HspFaxNum,"
+ " HspKorAdr, HspEngNam, HspEngAdr, HspRgnNum, HspInsNum, HspStrDte,"
+ " TO_CHAR(SYSDATE, 'YYYYMMDD') AS ServerToday FROM M_DepMst"
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
("d", code), ("y", date[..8]));
}
/// <summary>
/// 병리판독의(TLAB) 한 명 — <c>ETC_병리판독의사명/전문의번호</c>(bzDataInterface.vb:17318, 17515).
/// 원문에 ORDER BY 가 없어 TLAB 의사가 둘이면 이름과 번호가 <b>서로 다른 사람</b>이 될 수 있었다
/// (태그마다 따로 조회했으므로). 한 행을 UidCod 순으로 고정해 두 태그가 같은 사람을 가리키게 한다.
/// </summary>
public IReadOnlyDictionary<string, string> PathologyDoctor()
{
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidDtrYon = 'Y' AND TRIM(UidDepCod) = 'TLAB'"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
}
/// <summary>
/// 진단검사의(부서그룹 LAB) 한 명 — <c>ETC_진단검사의사명/전문의번호</c>(:17550, 17655).
/// 조건은 원문 그대로(의사 + 면허 보유 + 유효기간 + LAB 그룹 부서), 선택 규칙만 고정한다.
/// </summary>
public IReadOnlyDictionary<string, string> LabDoctor()
{
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT u.* FROM M_UidMst u"
+ " INNER JOIN M_DepMst d ON u.UidDepCod = d.DepCod AND d.DepGrpCod = 'LAB'"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte"
+ " WHERE u.UidDtrYon = 'Y' AND u.UidLicNum IS NOT NULL"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte"
+ " ORDER BY u.UidCod) WHERE ROWNUM = 1");
}
/// <summary>
/// 수술 일정(S_OprInf) 한 행 — <c>OCM_수술일자</c>·<c>ETC_수술일자_*</c> 계열이 쓴다.
/// <c>WHERE OprComNum = :c AND OprStt = 'E' ORDER BY OprKey</c> —
/// 무인자(Else 갈래)는 ASC(첫 수술), ETC_ 계열과 마지막수술은 DESC('수술실 요청' 주석 그대로).
/// GetOprInfDT(E_OprInf 축 큰 조회)와 <b>다른 표</b>다 — 이쪽은 수술 접수 일정이다.
/// </summary>
public IReadOnlyDictionary<string, string> ScheduledSurgery(decimal comNum, bool latestFirst)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT * FROM (SELECT * FROM S_OprInf WHERE OprComNum = :c AND OprStt = 'E'"
+ $" ORDER BY OprKey{(latestFirst ? " DESC" : string.Empty)}) WHERE ROWNUM = 1",
("c", comNum));
}
/// <summary>
/// 건물관리번호로 주소 — 레거시 <c>GetZipInfoByBuildingNumber</c>(clsCommonLib.vb:16600).
///
/// 결합식을 <b>그대로</b> 옮긴다. DECODE 로 빈 조각을 건너뛰고 구분자를 붙이는 규칙이
/// 도로명·지번·영문이 각각 다르다 — 손으로 다시 짜면 주소가 미묘하게 달라진다.
///
/// 없으면 빈 사전. 레거시도 못 찾으면 Nothing 을 돌려주고 호출부가 상세주소만 쓴다.
/// </summary>
public IReadOnlyDictionary<string, string> Address(string? buildingNumber)
{
var key = (buildingNumber ?? string.Empty).Trim();
if (key.Length == 0)
{
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
using var connection = new OracleConnection(connectionString);
connection.Open();
return Row(connection,
"SELECT 구역번호, 우편번호"
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
+ " || DECODE(TRIM(도로명), NULL, '', TRIM(도로명) || ' ')"
+ " || DECODE(지하여부, 1, '지하 ', '')"
+ " || DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번))"
+ " || DECODE(TRIM(건물번호부번), NULL, ' ', 0, ' ', '-' || TRIM(건물번호부번) || ' ')"
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 도로명주소"
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
+ " || DECODE(TRIM(법정동명), NULL, '', TRIM(법정동명) || ' ')"
+ " || DECODE(TRIM(리명), NULL, '', TRIM(리명) || ' ')"
+ " || DECODE(산여부, 1, ' 산 ', '')"
+ " || DECODE(TRIM(지번본번), NULL, '', TRIM(지번본번))"
+ " || DECODE(TRIM(지번부번), NULL, ' ', 0, ' ', '-' || TRIM(지번부번) || ' ')"
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 지번주소"
+ ", DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번) || ' ')"
+ " || DECODE(TRIM(도로명영문), NULL, '', TRIM(도로명영문) || ', ')"
+ " || DECODE(TRIM(읍면영문), NULL, '', TRIM(읍면영문) || ', ')"
+ " || DECODE(TRIM(시군구영문), NULL, '', TRIM(시군구영문) || ', ')"
+ " || TRIM(시도영문) AS 영문도로명주소"
+ " FROM M_ZipMst WHERE 건물관리번호 = :b", ("b", key));
}
#endregion
}
@@ -0,0 +1,925 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 환자 부가정보 태그들의 자기 SQL — 레거시 <c>bzDataInterface.vb</c> 의 함수 본문에서
/// 기계적으로 옮겼다(실제생년월일 :226 · 국적 :639 · 장애등급 :687 · 건보세대주명 :732 ·
/// 건보_급여_세대주명 :780 · 협력업체 :837 · 보호자연락처 :881).
///
/// <b>차트번호 매칭.</b> 레거시는 <c>PatChtNum</c>(CHAR 10) 원문 — 패딩 포함 — 을 바인드한다.
/// 우리 문맥은 행을 저장할 때 Trim 하므로(PatientContextStore.Row), CHAR 컬럼은
/// <c>RPAD(:c,10)</c> 으로 패딩을 되살려 비교한다(인덱스도 산다). P_pdsoInf 만 VARCHAR2 라
/// 저장값의 패딩 여부를 확정할 수 없어 두 형태 모두 본다(IN).
///
/// <b>Rows(0) 결정 규칙.</b> 원문은 전부 ORDER BY 없는 첫 행이다. 여기서는
/// 담당의 때 정한 규칙대로 정렬을 명시해 한 행으로 고정한다 — 각 메서드 주석에 근거를 남겼다.
/// </summary>
public sealed class PatientExtraStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public PatientExtraStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods
/// <summary>
/// 실제생년월일(P_PatEtcInf.PatEtcBirDte, YYYYMMDD 원문) — 없으면 빈 문자열.
/// 원문은 SELECT * 의 Rows(0) 인데 이 표는 차트번호가 사실상 유일키(환자 부가정보 마스터)다.
/// 만에 하나 다중이면 갱신일시 최신 행 — 마지막으로 저장한 값이 현재 값이라는 뜻이다.
/// </summary>
public string ActualBirthDate(string chtNum)
=> Scalar(
"SELECT PatEtcBirDte FROM (SELECT PatEtcBirDte FROM P_PatEtcInf"
+ " WHERE PatEtcChtNum = RPAD(:c, 10) ORDER BY PatEtcUpdDtf DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>보호자연락처(P_PatEtcInf.PatEtcGrdnPhn) — 원문의 IS NOT NULL 조건 그대로</summary>
public string GuardianPhone(string chtNum)
=> Scalar(
"SELECT PatEtcGrdnPhn FROM (SELECT PatEtcGrdnPhn FROM P_PatEtcInf"
+ " WHERE PatEtcChtNum = RPAD(:c, 10) AND PatEtcGrdnPhn IS NOT NULL"
+ " ORDER BY PatEtcUpdDtf DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 국적 한글명 — P_PatInf.PatNatCod 를 M_DtlMst(NATCOD) 로 푼다.
/// P_PatInf 는 차트번호당 1행, 조인 키(DtlTblCod+DtlCod)도 유일이라 정렬이 필요 없다.
/// </summary>
public string Nationality(string chtNum)
=> Scalar(
"SELECT DtlCodNam FROM P_PatInf"
+ " INNER JOIN M_DtlMst ON DtlCod = PatNatCod AND DtlTblCod = 'NATCOD'"
+ " WHERE PatChtNum = RPAD(:c, 10)",
("c", chtNum));
/// <summary>
/// 장애등급 — P_pdsoInf(PdsoFlg='E') 의 PdsoGrd. 호출부가 두 번째 글자를 뽑는다(레거시 그대로).
///
/// 원문(:701-710)은 P_ComInf 를 INNER JOIN 해 행을 내원 수만큼 곱해 놓고 RowNum 1 로
/// 접는다 — 조인의 실효는 "내원이 있는 환자인가" 확인뿐이라 EXISTS 로 바꿨다(의미 동일).
/// ORDER BY 없는 RowNum 1 은 등급 변경 이력이 있으면 임의 행이다 — 주석의 의도
/// ("현재 작성일 기준 등급", 2017-10-25 푸르메 요청)에 맞게 시작일 최신 행으로 고정한다.
/// </summary>
public string DisabilityGrade(string chtNum)
=> Scalar(
"SELECT PdsoGrd FROM (SELECT PdsoGrd FROM P_pdsoInf"
+ " WHERE PdsoChtNum IN (:c, RPAD(:c, 10)) AND PdsoFlg = 'E'"
+ " AND EXISTS (SELECT 1 FROM P_ComInf WHERE ComChtNum = RPAD(:c, 10))"
+ " ORDER BY PdsoStrDte DESC, PdsoSeq DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 건보 세대주명 — P_PISINF(PisInsCod='1') 를 이 내원의 자격(P_CoiInf.CoiInsSeq)과 맞춘다.
/// 자격 이력이 여러 건이면 원문은 임의 행 — 시작일 최신 행으로 고정한다.
/// </summary>
public string HouseholderName(string chtNum, decimal comNum)
=> Scalar(
"SELECT Pis1SedaejuNm FROM (SELECT Pis1SedaejuNm FROM P_PISINF"
+ " INNER JOIN P_CoiInf ON CoiInsSeq = PisInsSeq AND CoiComNum = :m"
+ " WHERE PisChtNum = RPAD(:c, 10) AND PisInsCod = '1'"
+ " ORDER BY PisStrDte DESC) WHERE ROWNUM = 1",
("m", comNum), ("c", chtNum));
/// <summary>
/// 세대주명(건보·급여) — 문맥 자격의 (보험코드, 순번)으로 P_PISINF 를 직접 짚는다.
/// 레거시(:780-)는 참조 내원이 있으면 그쪽 자격을 쓰지만 미리보기는 참조를 설정하지
/// 않으므로(TestPatientSetting) 자기 내원 갈래만 옮겼다.
/// </summary>
public string HouseholderNameByInsurance(string chtNum, string insSeq, string insCod)
=> Scalar(
"SELECT Pis1SedaejuNm FROM (SELECT Pis1SedaejuNm FROM P_PISINF"
+ " WHERE PisChtNum = RPAD(:c, 10) AND PisInsSeq = :s AND PisInsCod = :d"
+ " ORDER BY PisStrDte DESC) WHERE ROWNUM = 1",
("c", chtNum), ("s", insSeq), ("d", insCod));
/// <summary>
/// 협력업체 한글명 — 내원행의 ComCoopHsp 를 M_DtlMst 로 푼다.
/// 원문(:850-854)은 DtlTblCod 조건 없이 DtlCod 만으로 조인한다 — 같은 코드가 다른
/// 코드표에도 있으면 다중행이다. 레거시 조인을 그대로 두되 테이블코드 순 첫 행으로 고정한다.
/// </summary>
public string Cooperator(decimal comNum)
=> Scalar(
"SELECT DtlCodNam FROM (SELECT DtlCodNam FROM P_ComInf"
+ " INNER JOIN M_DtlMst ON ComCoopHsp = DtlCod"
+ " WHERE ComNum = :m ORDER BY DtlTblCod) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 퇴원 이후 다음 외래 방문(OCM_FollowUp, :1028) — 지금보다 뒤의 취소 아닌 내원 중
/// 가장 가까운 것의 접수일시 12자리. 원문의 ROWNUM 은 ORDER BY 보다 먼저 걸려
/// 임의 행이었다 — 인라인뷰로 정렬을 먼저 걸어 "가장 가까운 미래 내원"으로 고정한다.
/// "지금"은 서버 시각이다(레거시 SystemDateTime 과 같은 의미).
/// </summary>
public string FollowUp(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10)"
+ " AND ComAcpDtm > TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')"
+ " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 외출·외박 구간의 한쪽 끝(OCM_외출외박신청일/종료일, :1883/:1936) — 오늘을 포함하는
/// 외박 허용(CowSlpOut='Y') 병실 행의 CowStrDtm/CowEndDtm.
/// <b>0행이면 서버 현재시각을 돌려준다</b> — 특이하지만 레거시가 그렇다
/// (기록이 없으면 "지금"이 종이에 찍힌다). P_RblInf 곱으로 다중행이면 원문은
/// 임의 행이었다 — 시작일시 최신 행으로 고정한다.
/// </summary>
public string LeaveOfAbsence(decimal comNum, bool endSide)
{
var column = endSide ? "CowEndDtm" : "CowStrDtm";
// "오늘"은 서버 날짜다(레거시 SystemDate 와 같은 의미) — 바인드 대신 SYSDATE 로 쓴다
return Scalar(
"SELECT NVL(MAX(v), TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')) FROM ("
+ $"SELECT v FROM (SELECT {column} v FROM P_CoiInf"
+ " INNER JOIN P_ComInf ON ComNum = CoiComNum AND ComPatTyp = 'I' AND ComAcpStt <> 'IC'"
+ " INNER JOIN P_CowInf ON CowComNum = CoiComNum"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' BETWEEN CowStrDtm AND CowEndDtm"
+ " INNER JOIN P_RblInf ON RblComNum = CoiComNum AND CoiMtiSeq = RblMtiSeq"
+ " AND RblStrDte BETWEEN CoiStrDte AND CoiEndDte"
+ " WHERE CoiComNum = :m AND RblDtrCod = '-' AND RblNxtNum = 0 AND RblIncTyp = 'M'"
+ " AND CowSlpOut = 'Y'"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN CoiStrDte AND CoiEndDte"
+ " ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1)",
("m", comNum));
}
/// <summary>
/// 최초 내원일(OCM_최초내원일, :1988 Case Else) — 이 내원 이전의 취소 아닌 재진(ComNfsTyp='2')
/// 중 가장 이른 접수일시. 원문이 인라인뷰 안에 ORDER BY 를 걸어 이 쿼리는 원래 결정적이다.
/// P_CodInf 조인은 원문 그대로(진료 행이 있는 내원만) — 곱행이 생겨도 값이 같은 컬럼이라 무해.
/// KIMEYE 분기(초재진 무구분, ComNfsTyp 우선 정렬)는 옮기지 않았다.
/// </summary>
public string FirstVisit(string chtNum, string acpDtm)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
+ " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'"
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum), ("a", acpDtm));
/// <summary>
/// 진료과별 초진일(OCM_초진일_발병일 :2040 · OCM_신환_초진일 :2138) — 최초 내원일에
/// 진료과 앞 2자 조건이 붙는다. 신환 포함이면 ComNfsTyp IN ('1','2'), 아니면 '2' 만.
/// </summary>
public string FirstVisitByDepartment(string chtNum, string acpDtm, string depCod2, bool includeNew)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
+ (includeNew ? " AND ComAcpStt <> 'OC' AND ComNfsTyp IN ('1','2')"
: " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'")
+ " AND SUBSTR(CodDepCod, 1, 2) = :p"
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum), ("a", acpDtm), ("p", depCod2));
/// <summary>
/// 낮병동 입원일자(OCM_입원일자_낮병동, :7260) — 이 내원의 접수일시를 포함하는
/// 낮병동 신청(P_DcpInf, DcpAcpStt='A') 구간의 시작일. 원문 Rows(0) 은 구간이
/// 겹치면 임의 행이었다 — 시작일 최신 행으로 고정한다.
/// </summary>
public string DayCareAdmissionDate(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT DcpStrDte v FROM P_ComInf"
+ " INNER JOIN P_DcpInf ON ComChtNum = DcpChtNum"
+ " AND ComAcpDtm BETWEEN DcpStrDte AND DcpEndDte || '2359'"
+ " AND DcpAcpStt = 'A'"
+ " WHERE ComNum = :m AND ComAcpStt <> 'OC' AND ComDayCar = 'Y'"
+ " ORDER BY DcpStrDte DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 혈액형(OCM_혈액형, :11783) — S_BlpInf.BlpAboTyp. 원문 Rows(0) 에 ORDER BY 가 없다 —
/// 갱신일시 최신 행으로 고정한다(마지막 판정이 현재 혈액형이다).
/// BlpChtNum 은 VARCHAR2 라 저장 형태를 확정할 수 없어 두 형태 모두 본다.
/// </summary>
public string BloodType(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT BlpAboTyp v FROM S_BlpInf"
+ " WHERE BlpChtNum IN (:c, RPAD(:c, 10))"
+ " ORDER BY BlpUpdDtm DESC NULLS LAST) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 최종 월경일(OCM_LMP, :11813) — O_PrgInf.PrgLmpDte. 원문 Rows(0) 에 ORDER BY 가 없다 —
/// 같은 표를 읽는 임신주기(:11845)가 갱신일시 최신순을 쓰므로 그 정렬로 고정한다.
/// </summary>
public string Lmp(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT PrgLmpDte v FROM O_PrgInf"
+ " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 발병일(OCM_발병일, :8125) — 대표 주상병(OkdSeq=1, OkdStt='E')과 오늘 유효한
/// 발병 기록(O_OnsInf)의 시작일. 원문 Rows(0) 에 ORDER BY 가 없다 —
/// 발병 시작일 최신 행으로 고정한다.
/// </summary>
public string OnsetDate(string chtNum, decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT OnsStrDte v FROM O_OkdInf"
+ " INNER JOIN O_OnsInf ON OkdKcdCod = OnsKcdCod"
+ " AND OnsChtNum = RPAD(:c, 10)"
+ " AND (OnsDelDtf IS NULL OR OnsDelDtf = ' ')"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN OnsStrDte AND OnsEndDte"
+ " WHERE OkdComNum = :m AND OkdSeq = 1 AND OkdStt = 'E'"
+ " ORDER BY OnsStrDte DESC) WHERE ROWNUM = 1",
("c", chtNum), ("m", comNum));
/// <summary>
/// 당일 식이(OCM_당일식이, :12620) — 오늘(SYSDATE) 입원 식이의 아침/점심/저녁 PIVOT 행.
/// PIVOT 이 내원 단위로 접으므로 결과는 최대 1행 — 원문 그대로 결정적이다.
/// </summary>
public IReadOnlyDictionary<string, string> TodayMeals(decimal comNum)
=> Row(
"WITH ComIml AS (SELECT A01.ImlMgdTyp MgdTyp, B01.MgdKorNam MgdNam, A01.ImlComNum"
+ " FROM O_ImlInf A01 INNER JOIN M_MgdMst B01 ON MgdCod = ImlMgdCod"
+ " WHERE ImlDte = TO_CHAR(SYSDATE, 'YYYYMMDD') AND ImlPatTyp = 'I' AND ImlComNum = :m)"
+ " SELECT * FROM ComIml"
+ " PIVOT (MAX(MgdNam) AS MgdTyp FOR MgdTyp IN ('B' 아침, 'L' 점심, 'D' 저녁))",
("m", comNum));
/// <summary>
/// 알러지(OCM_알러지·_조영제·_약물·_음식·_기타, :14941-15140) — 오늘 유효한 코드마스터
/// (ALLERGY)에 매칭되는 행을 SQL LISTAGG 로 CRLF 결합. category 가 null 이면 전체.
/// 집계 쿼리라 항상 1행 — 매칭 0건이면 NULL → 빈 문자열이다(원문과 같은 귀결).
/// </summary>
public string Allergy(string chtNum, string? category)
{
var filter = category is null ? string.Empty : " AND AlgAlgCtg = :g";
var binds = category is null
? new (string, object)[] { ("c", chtNum) }
: new (string, object)[] { ("c", chtNum), ("g", category) };
return Scalar(
"SELECT LISTAGG(AlgAlgCtg || '-' || CASE WHEN NVL(AlgAlgSpm, ' ') <> ' '"
+ " THEN AlgAlgTyp || ' (증상 : ' || AlgAlgSpm || ')' ELSE AlgAlgTyp END,"
+ " CHR(13) || CHR(10)) WITHIN GROUP (ORDER BY DtsDtlCod, DtsDspSeq)"
+ " FROM S_AlgInf INNER JOIN M_DtsMst ON DtsTblCod = 'ALLERGY'"
+ " AND AlgAlgTyp = DtsCodNam"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D')" + filter,
binds);
}
/// <summary>
/// 알러지 원장 행들(OCM_알러지_List, :15142) — 코드마스터 조인 없이 전부,
/// 갱신일시 오름차순(원문 ORDER BY 그대로). 결합은 해석기가 원문 결함까지 흉내낸다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> AllergyRows(string chtNum)
=> Rows(
"SELECT AlgAlgCtg, AlgAlgTyp, AlgAlgSpm FROM S_AlgInf"
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D') ORDER BY AlgUpdDtf ASC",
("c", chtNum));
/// <summary>
/// 외출 예상 일시(OCM_외출예상시작/종료일시, :15197/:15231) — 오늘 0시 이후 시작하는
/// 외출·외박 신청 중 가장 이른 것. 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다.
/// 두 태그가 같은 행의 양 끝을 읽는다(정렬 키는 시작 쪽).
/// </summary>
public string ExpectedOuting(string chtNum, bool endSide)
=> Scalar(
"SELECT " + (endSide ? "NVL(OutAdmEndDtm, OutAdmExpEndDtm)" : "NVL(OutAdmStrDtm, OutAdmExpStrDtm)")
+ " FROM (SELECT * FROM O_OutAdmInf"
+ " WHERE OutAdmChtNum IN (:c, RPAD(:c, 10))"
+ " AND OutAdmStrDtm >= TO_CHAR(SYSDATE, 'YYYYMMDD') || '0000'"
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm)) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 건강보험증번호(ETC_건강보험증번호, :15269) — 건보(PisInsCod=1) 자격의 최신 순번 행.
/// 원문의 숫자 리터럴 비교(PisInsCod = 1)와 TO_NUMBER 정렬을 그대로 옮겼다.
/// </summary>
public string InsuranceCertNumber(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT Pis1AsylmSym v FROM P_PisInf"
+ " WHERE PisInsCod = 1 AND PisChtNum = RPAD(:c, 10)"
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>재해발생일(ETC_재해발생일, :15323) — 산재(PisInsCod=4) 자격의 최신 순번 행. YYYYMMDD 원문 그대로</summary>
public string AccidentDate(string chtNum)
=> Scalar(
"SELECT v FROM (SELECT Pis4AcdDte v FROM P_PisInf"
+ " WHERE PisInsCod = 4 AND PisChtNum = RPAD(:c, 10)"
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 산재지정번호(ETC_산재지정번호, :15727) — 진료과로 병원을 찾아 HspWrkNum.
/// 요양기관 행과 같은 조인이라 다중이면 한 행으로 고정한다.
/// </summary>
public string IndustrialAccidentNumber(string depCod, string day)
=> depCod.Trim().Length == 0 || day.Trim().Length < 8 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT HspWrkNum v FROM M_DepMst"
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
("d", depCod.Trim()), ("y", day.Trim()[..8]));
/// <summary>
/// 감염 분류·정보(OCM_감염분류_감염정보, :14084) — 오늘 유효한 감염 기록을
/// "분류 앞 2자-상세명" 으로 LISTAGG CRLF 결합. 집계라 항상 1행이다.
/// (OCM_감염정보 :14122 는 원문 SQL 의 바인드 콜론이 빠져 항상 오류→빈 값 —
/// 해석기가 사유로 말하고 여기서는 구현하지 않는다.)
/// </summary>
public string InfectionInfo(string chtNum)
=> Scalar(
"SELECT LISTAGG(SUBSTR(DtlCodNam, 1, 2) || '-' || DtsCodNam, CHR(13) || CHR(10))"
+ " WITHIN GROUP (ORDER BY DtsDspSeq)"
+ " FROM S_IcnInf"
+ " INNER JOIN M_DtlMst ON DtlCod = IcnIcnCod AND DtlTblCod = 'INFECTION'"
+ " INNER JOIN M_DtsMst ON DtsCod = IcnDtlCod"
+ " WHERE IcnChtNum IN (:c, RPAD(:c, 10))"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN IcnStrDte AND IcnEndDte",
("c", chtNum));
/// <summary>
/// 퇴원분석 수술·처치명 목록(OCM_퇴원분석_수술처치명, :13920) — "코드 - 이름" 을
/// 순번 순으로. 결합은 해석기가 한다(원문은 AppendLine 이라 마지막 뒤에도 개행이 남는다).
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> DischargeAnalysisOperations(decimal comNum)
=> Rows(
"SELECT DssOpnCod || ' - ' || DssOpnNam AS OpnNam FROM S_DssOpn"
+ " WHERE DssComNum = :m"
+ " AND (DssOpnCod IS NOT NULL AND DssOpnCod <> ' ')"
+ " AND (DssOpnNam IS NOT NULL AND DssOpnNam <> ' ')"
+ " ORDER BY DssOpnSeq",
("m", comNum));
/// <summary>
/// 병실 인실(OCM_병실인실, :7065) — 입실 이력과 병실 마스터의 배정 침상 수.
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 전실했으면 임의 병실이었다. 최신 입실로 고정한다.
/// </summary>
public string RoomBedCount(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT RomAsgBed v FROM P_CowInf"
+ " INNER JOIN M_RomMst ON RomCod = CowRomCod"
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
+ " WHERE CowComNum = :m ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 병실 차액 구분(OCM_병실차액, :7103) — 첫 입실 병실 등급의 건보 산정 구분(SgaHthIns).
/// 원문이 CowStrDtm 오름차순 첫 행이라 결정적이다 — 그대로 옮겼다.
/// </summary>
public string RoomExtraCharge(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT SgaHthIns v FROM P_ComInf"
+ " INNER JOIN P_CowInf ON CowComNum = ComNum"
+ " INNER JOIN M_RomMst ON CowRomCod = RomCod"
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
+ " INNER JOIN M_RgdMst ON CowGrdCod = RgdCod"
+ " AND CowStrDtm BETWEEN RgdStrDte AND RgdEndDte || '2359'"
+ " INNER JOIN M_SgaMst ON RgdAmtCod = SgaCod"
+ " AND CowStrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 계산유형 이름(OCM_계산유형, :5133) — 자격의 보험·계산 코드로 M_CalMst.CalNam.
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 시작일 최신 행으로 고정한다.
/// </summary>
public string CalculationTypeName(string insCod, string calCod, string strDte)
=> Scalar(
"SELECT v FROM (SELECT CalNam v FROM M_CalMst"
+ " WHERE CalInsCod = :i AND CalCod = :l AND :d BETWEEN CalStrDte AND CalEndDte"
+ " ORDER BY CalStrDte DESC) WHERE ROWNUM = 1",
("i", insCod), ("l", calCod), ("d", strDte));
/// <summary>보험유형 이름(OCM_보험유형, :5186) — M_InsMst.InsNam. 같은 규칙으로 고정</summary>
public string InsuranceTypeName(string insCod, string strDte)
=> Scalar(
"SELECT v FROM (SELECT InsNam v FROM M_InsMst"
+ " WHERE InsCod = :i AND :d BETWEEN InsStrDte AND InsEndDte"
+ " ORDER BY InsStrDte DESC) WHERE ROWNUM = 1",
("i", insCod), ("d", strDte));
/// <summary>
/// 수납일시(OCM_수납일시, :5287) — 자격에 걸린 수납청구(P_RblInf, RblCalNxt=0)의
/// 수납(P_RcpInf) 갱신일시 12자리. 원문은 두 쿼리 모두 ORDER BY 없는 Rows(0) —
/// 청구·수납 다건이면 임의였다. 최신 청구의 최신 수납으로 고정하고 한 왕복으로 접었다.
/// </summary>
public string ReceiptStamp(decimal comNum, string mtiSeq, string strDte)
=> Scalar(
"SELECT v FROM (SELECT RcpUpdDtf v FROM P_RcpInf WHERE RcpRblNum IN ("
+ " SELECT n FROM (SELECT RblNum n FROM P_RblInf"
+ " WHERE RblComNum = :m AND RblMtiSeq = :s AND RblStrDte = :d AND RblCalNxt = 0"
+ " ORDER BY RblNum DESC) WHERE ROWNUM = 1)"
+ " ORDER BY RcpUpdDtf DESC) WHERE ROWNUM = 1",
("m", comNum), ("s", mtiSeq), ("d", strDte));
/// <summary>
/// 최신 퇴원약 오더 일시(OCM_진료기간 SRCH 갈래, :2299-2318) — 인라인뷰 정렬이
/// 원문부터 있어 결정적이다. 필터(처방의약·숨김 제외 등)는 원문 그대로.
/// </summary>
public string DischargeMedicationOrderDate(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT OdrDtm v FROM O_OdrInf"
+ " WHERE OdrComNum = :m AND OdrStt = 'OE'"
+ " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ " AND OdrGrpSub = 0"
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
+ " ORDER BY OdrDtm DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 외래 진료기간 시작(OCM_진료기간 외래 갈래, :2325-2345) — 이 내원 이하의 취소 아닌
/// 최초 접수일시. OCM_최초내원일과 달리 진료행 조인·재진 필터가 없다(원문 그대로 —
/// ComNfsTyp='N' 조건은 주석 처리돼 있어 옮기지 않는다).
/// </summary>
public string TreatmentPeriodStart(string chtNum, string acpDtm)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum), ("a", acpDtm));
/// <summary>외래 진료기간 끝(:2352-2372) — 시작 이후 오늘(2359)까지의 최신 접수일시</summary>
public string TreatmentPeriodEnd(string chtNum, string strDte)
=> Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm >= :s"
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
("c", chtNum), ("s", strDte));
/// <summary>
/// 응급증상 오더의 산정 여부(OCM_응급증상여부_YES/NO, :12748/:12830) — 응급 내원에
/// 걸린 응급의료관리료 오더 중 한 행의 OdrInsYon. 행이 없으면 null(레거시 $NoCheck$ —
/// 체크 상태를 정하지 않는다). 원문 Rows(0) 에 ORDER BY 가 없다 — 오더일시 최신으로
/// 고정한다. 코드 목록은 레거시 GetDtlMst("EMGEMGCOD") 그대로: 코드표에 있으면 그것,
/// 없으면 하드코딩 6종(레거시 폴백).
/// </summary>
public string? EmergencySymptomInsYon(decimal comNum)
{
var codes = Rows("SELECT DtlCod FROM M_DtlMst WHERE DtlTblCod = 'EMGEMGCOD' ORDER BY DtlCod")
.Select(r => r.TryGetValue("DtlCod", out var v) ? v : string.Empty)
.Where(v => v.Length > 0)
.ToArray();
if (codes.Length == 0)
{
codes = new[] { "V1200", "V1300", "V1400", "V1500", "AC103", "AC105" };
}
var placeholders = string.Join(", ", codes.Select((_, i) => $":g{i}"));
var binds = new List<(string, object)> { ("m", comNum) };
for (var i = 0; i < codes.Length; i++)
{
binds.Add(($"g{i}", codes[i]));
}
var value = Scalar(
"SELECT v FROM (SELECT OdrInsYon v FROM S_EmgInf OCMEMG"
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE OCMEMG.EmgComNum = :m"
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ $" AND OdrElcCod IN ({placeholders})"
+ " ORDER BY OdrDtm DESC, OdrKey DESC) WHERE ROWNUM = 1",
binds.ToArray());
// 빈 문자열은 "행은 있는데 값이 NULL"과 "행이 없다"를 못 가른다 — COUNT 로 가른다
if (value.Length > 0)
{
return value;
}
var count = Scalar(
"SELECT COUNT(*) FROM S_EmgInf OCMEMG"
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE OCMEMG.EmgComNum = :m"
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ $" AND OdrElcCod IN ({placeholders})",
binds.ToArray());
return count == "0" ? null : string.Empty;
}
/// <summary>
/// 전과 내역(OCM_전과내역, :1103) — 진료 행 전부를 과명·구간과 함께.
/// 원문 조인 그대로: 과 조인이 담당의 조인의 병원 코드에 종속돼(DepHspCod=Usr.UidHspCod)
/// 의사 매칭이 빠지면 과명도 빈다. 정렬(CodStrDtm)이 원문부터 있어 결정적이다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> TransferHistory(decimal comNum)
=> Rows(
"SELECT DepKorNam AS DepNam, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
+ " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
+ " AND Cod.CodEndDtm BETWEEN Usr.UidStrDte AND Usr.UidEndDte || '2359'"
+ " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
+ " AND DepWrkTyp = 'D' AND Cod.CodEndDtm BETWEEN DepStrDte AND DepEndDte || '2359'"
+ " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
("m", comNum));
/// <summary>
/// 청구 진료과목 이름(OCM_진단서청구진료과목_로그인, :4790) — 진료과의 청구코드
/// (DepBilCod)를 코드표(BDPTBL)로 푼다. 기준일은 오늘(원문 SystemDate).
/// 원문 Rows(0) 무가드 — 한 행으로 고정한다.
/// </summary>
public string BillingDepartmentName(string depCod)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT DtlCodNam v FROM M_DepMst"
+ " INNER JOIN M_DtlMst ON DepBilCod = DtlCod AND DtlTblCod = 'BDPTBL'"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
+ " WHERE TRIM(DepCod) = :d"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte)"
+ " WHERE ROWNUM = 1",
("d", depCod.Trim()));
/// <summary>
/// 당뇨식 칼로리(OCM_당뇨식칼로리_List, :3569) — 당뇨식 중 최신 식이일의 칼로리 1건.
/// 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다(식이일 동률만 모호).
/// </summary>
public string DiabetesMealCalorie(string chtNum)
=> Scalar(
"SELECT ImlCal FROM (SELECT ImlCal FROM O_ImlInf"
+ " INNER JOIN P_ComInf ON ComNum = ImlComNum AND ComChtNum = RPAD(:c, 10)"
+ " INNER JOIN M_MgdMst ON MgdCod = ImlMgdCod"
+ " WHERE MgdKorNam LIKE '%당뇨%' AND TO_NUMBER(ImlCal) > 0"
+ " ORDER BY ImlDte DESC) WHERE ROWNUM = 1",
("c", chtNum));
/// <summary>
/// 수술 신청의 수술일자(OCM_수술일자 Case Else :2977 · OCM_수술일자_마지막수술 :2999) —
/// 확정(OprStt='E') 수술의 OprDte. 본체는 첫 수술(OprKey ASC), 마지막수술은 최신
/// (OprKey DESC). PURME(마취 오더)·BSGH/HIMCHAN/GJHNSS(DESC 뒤집기) 분기는 미이식.
/// </summary>
public string SurgeryDate(decimal comNum, bool latestFirst)
=> Scalar(
"SELECT v FROM (SELECT OprDte v FROM S_OprInf"
+ " WHERE OprComNum = :m AND OprStt = 'E'"
+ $" ORDER BY OprKey {(latestFirst ? "DESC" : "ASC")}) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 외래 실진료일 목록(OCM_외래실진료일 :1217 · _과목별 :1503) — 신환('1') 첫 접수
/// (없으면 전체 첫 접수)부터 마지막 접수까지, 지정 진료과의 접수일시 전부(시간순).
/// exactDepartment=false 면 과 앞 2자 매칭(본체), true 면 전체 코드 일치(과목별).
/// 원문 세 선행 쿼리 모두 인라인뷰 정렬 + ROWNUM 이라 결정적이다.
/// 본체 원문의 종료일 쿼리는 차트번호를 문자열 연결로 넣지만 값이 같아 바인드로 정상화했다.
/// </summary>
public IReadOnlyList<string> OutpatientVisitDays(string chtNum, string acpDtm, string depCod, bool exactDepartment)
{
var first = Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
+ " AND ComAcpStt <> 'OC' AND ComNfsTyp = '1'"
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
("c", chtNum), ("a", acpDtm));
if (first.Length < 8)
{
first = Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm ASC) WHERE ROWNUM = 1",
("c", chtNum));
}
if (first.Length < 8)
{
return Array.Empty<string>();
}
var last = Scalar(
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'"
+ " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
("c", chtNum));
var depFilter = exactDepartment
? " AND CodDepCod = :p" : " AND SUBSTR(CodDepCod, 1, 2) = :p";
return Rows(
"SELECT ComAcpDtm FROM P_ComInf"
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
+ " WHERE ComChtNum = RPAD(:c, 10)"
+ " AND ComAcpDtm >= :s || '0000' AND ComAcpDtm <= :e || '9999'"
+ depFilter + " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm",
("c", chtNum), ("s", first[..8]), ("e", last.Length >= 8 ? last[..8] : string.Empty),
("p", depCod))
.Select(r => r.TryGetValue("ComAcpDtm", out var v) ? v : string.Empty)
.Where(v => v.Length >= 8)
.ToArray();
}
/// <summary>
/// 적용 진료과 이름(OCM_적용진료과, :5659) — 통합 대표행의 AdpDepCod/AdpDtm 은
/// 진료행(CodDepCod)과 적용일시의 별칭이다(dtHISOperatingInfo.vb:3699).
/// 원문은 적용일시 <b>12자리 전체</b>를 8자리 유효기간과 비교한다(절단 없음) — 그대로.
/// </summary>
public string AppliedDepartmentName(string depCod, string adpDtm)
=> depCod.Trim().Length == 0 || adpDtm.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT DepKorNam v FROM M_DepMst"
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte)"
+ " WHERE ROWNUM = 1",
("d", depCod.Trim()), ("y", adpDtm.Trim()));
/// <summary>
/// 전과 목록(OCM_전과, :12924 — GetCodInfDT(With_Join, 전체) 대응, dtCommonLib.vb:2777-2832).
/// 원문의 이름 조인(오늘 기준 유효기간, DepWrkTyp IN ('D','A') — SYBS·NYHY 의 'B' 추가는
/// 미이식)과 정렬(CodStrDtm 오름차순) 그대로. 원문이 DefaultView 에 건 필터·정렬은
/// 순회에 반영되지 않지만 SQL 이 이미 같은 조건·순서라 실질 무해했다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> TransferList(decimal comNum)
=> Rows(
"SELECT DepKorNam AS DepNam, Cod.CodDepCod, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
+ " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN Usr.UidStrDte AND Usr.UidEndDte"
+ " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
+ " AND DepWrkTyp IN ('D','A')"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
("m", comNum));
/// <summary>
/// 간호정보조사지의 과거력 행(OCM_간호정보조사지_과거력 :13030 + GetNurseEmrDT :13523 +
/// GetNurseShtCod :13770) — 서식 우선순위(S113 성인 → S202 소아 → S163 산모, SRCH 갈래)로
/// 고른 기록의 병력 항목 6칸을 PIVOT 한 행. 레거시는 서식 확인 3왕복 + 본조회였다 —
/// 우선순위를 CASE 정렬로 접어 한 왕복이다. 기록이 없으면 빈 사전.
/// </summary>
public IReadOnlyDictionary<string, string> NurseHistoryRow(decimal comNum)
=> Row(
"WITH Sht AS (SELECT v FROM (SELECT EmrShtCod v FROM E_EmrInf"
+ " WHERE EmrComNum = :m AND EmrShtCod IN ('S113','S202','S163')"
+ " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D')"
+ " ORDER BY CASE EmrShtCod WHEN 'S113' THEN 1 WHEN 'S202' THEN 2 ELSE 3 END)"
+ " WHERE ROWNUM = 1),"
+ " Data AS (SELECT SctObjNam, EmdRecord, ShtCod FROM E_EmrInf"
+ " INNER JOIN E_ShtMst ON ShtCod = EmrShtCod"
+ " INNER JOIN E_EmdInf ON EmdEmrKey = EmrKey"
+ " INNER JOIN E_SctMst ON SctKey = EmdItmKey AND SctObjNam IN"
+ " ('chkMedHis_1','chkMedHis_2','chkMedHis_3','chkMedHis_4','chkMedHis_5','txtMedHis')"
+ " WHERE EmrComNum = :m AND EmrShtCod = (SELECT v FROM Sht)"
+ " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D'))"
+ " SELECT * FROM (SELECT EmdRecord, SctObjNam, ShtCod FROM Data)"
+ " PIVOT (MIN(EmdRecord) FOR SctObjNam IN ('chkMedHis_1' AS 당뇨, 'chkMedHis_2' AS 고혈압,"
+ " 'chkMedHis_3' AS 간염, 'chkMedHis_4' AS 결핵, 'chkMedHis_5' AS 병력기타,"
+ " 'txtMedHis' AS 병력기타내용))",
("m", comNum));
/// <summary>
/// 영상 촬영 이력(OCM_영상촬영이력, :13968) — 영상의학(XRAY) 접수 완료 오더의
/// 일자·이름(30자 절단, 원문 SUBSTR). 원문 정렬은 8자 절단 별칭이라 같은 날 안 순서가
/// 임의였다 — 원본 일시로 정렬해 고정한다(상위 순서는 동일).
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> RadiologyHistory(decimal comNum)
=> Rows(
"SELECT SUBSTR(OdrDtm, 1, 8) AS OdrDay, SUBSTR(OdrNam, 1, 30) AS OdrNam FROM O_OdrInf"
+ " WHERE OdrComNum = :m AND OdrAcpDep = 'XRAY' AND OdrStkStt = 'OE'"
+ " ORDER BY OdrDtm DESC",
("m", comNum));
/// <summary>
/// 나이(개월 표기 포함) — 통합 대표행의 AgeMonth 파생 컬럼과 같은 식
/// (dtCommonLib.vb:3725 — UDF_GETAGE_FROMRESNUM(복호화 주민번호, 적용일시)).
/// PAT_나이의 Case Else 갈래가 이 컬럼을 그대로 돌려준다(CGCH 분기 미이식).
/// 암호화 N 병원 갈래(PatResNum 직접)는 미이식 — 이 DB 는 암호화 Y 다.
/// </summary>
public string AgeMonthText(string chtNum, string adpDtm)
=> Scalar(
"SELECT UDF_GETAGE_FROMRESNUM(UDF_GetDecResNum(PatResEnc), :a) FROM P_PatInf"
+ " WHERE PatChtNum = RPAD(:c, 10)",
("a", adpDtm), ("c", chtNum));
/// <summary>
/// 정수 나이 — 통합 대표행의 AGE 파생 컬럼과 같은 식
/// (dtCommonLib.vb:3712 — UDF_GETAGE(PatBthDay, 적용일 8자)).
/// PAT_나이_세의 산정코드 24* 갈래가 이 컬럼을 쓴다.
/// </summary>
public string AgeYearsUdf(string chtNum, string day8)
=> Scalar(
"SELECT UDF_GETAGE(PatBthDay, :a) FROM P_PatInf WHERE PatChtNum = RPAD(:c, 10)",
("a", day8), ("c", chtNum));
/// <summary>
/// 수술 집도의의 진료과/그룹진료과 이름(OCM_수술집도의_진료과 :3157 · _그룹진료과 :3194) —
/// 수술 신청의 집도의(S_OprTyp OprTyp='DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 푼다.
/// 원문 특이점 그대로: OprStt 필터가 없고(취소 수술 포함), 유효기간이 수술일이 아니라
/// <b>오늘</b> 기준이다. 최신 수술(OprKey DESC) 첫 행으로 접는다(원문 Rows(0)).
/// </summary>
public string SurgeonDepartment(decimal comNum, bool groupName)
=> Scalar(
"SELECT v FROM (SELECT "
+ (groupName ? "DtlCodNam" : "DepKorNam") + " v FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'DTRUIDCOD'"
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ (groupName
? " INNER JOIN M_DtlMst ON DepGrpCod = DtlCod AND DtlTblCod = 'GDPTBL'"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
: string.Empty)
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 수술명 목록(OCM_수술명칭, :3520 Case Else) — 확정 수술의 수술코드명(OprTyp='OPRCOD')
/// 을 수술일 순으로. GNBEDRO·HIMCHAN_BP 의 OPNAME 포함 분기는 미이식.
/// 결합(첫 행 뒤 공백, 이후 CRLF)은 해석기가 원문대로 한다.
/// </summary>
public IReadOnlyList<string> SurgeryNames(decimal comNum)
=> Rows(
"SELECT OprCodNam FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKeyNum = OprKey"
+ " WHERE OprComNum = :m AND OprStt = 'E' AND OprTyp = 'OPRCOD'"
+ " ORDER BY OprDte",
("m", comNum))
.Select(r => r.TryGetValue("OprCodNam", out var v) ? v : string.Empty)
.ToArray();
/// <summary>
/// 퇴원약 목록(퇴원약DT :10482 — 파생 6종·OCM_퇴원약·_New 의 공통 조회).
/// 퇴원의약(OdrDscMed) 오더를 일시·코드·이름 순으로. 1회 투여량(QtyTmsQty)은
/// SQL 계산 그대로(수량형 '*' 이면 원량, 아니면 총량/횟수 반올림 3자리).
/// OdrRefCmt 는 OCM_퇴원약(SATCH 분기)용 컬럼 — 파생 6종은 쓰지 않는다.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> DischargeMedications(decimal comNum)
=> Rows(
"SELECT OdrCod, OdrNam, OdrTms, OdrDay, UsgKorNam, OdrRefCmt,"
+ " CASE WHEN OdrQtyTyp = '*' THEN OdrDtrQty ELSE ROUND(OdrDtrQty / OdrTms, 3) END AS QtyTmsQty"
+ " FROM O_OdrInf"
+ " LEFT JOIN M_UsgMst ON OdrUsgCod = UsgCod"
+ " AND OdrDtm BETWEEN UsgStrDte || '0000' AND UsgEndDte || '2359'"
+ " WHERE OdrComNum = :m AND OdrStt = 'OE'"
+ " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ " AND OdrGrpSub = 0"
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
+ " ORDER BY OdrDtm, OdrCod, OdrNam",
("m", comNum));
/// <summary>
/// 비급여 오더 행들(OCM_비급여처방, :10964 — Case Else + SRCH 조건 갈래).
/// SRCH 는 고지 대상(SgaNtiNon='Y')이고 수가 그룹(SgaHgnCod)이 있는 것만 본다.
/// TYRCH(UNION)·BSGH(치료실)·GNRH·BSQ·SUSS 분기는 미이식.
/// </summary>
public IReadOnlyList<IReadOnlyDictionary<string, string>> NonInsuredOrders(decimal comNum)
=> Rows(
"SELECT SgaKorNam, SgaHthNon, SgaGudNon, SgaCarNon, SgaWrkNon, SgaGenFor, SgaGenNon"
+ " FROM O_OdrInf"
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
+ " LEFT JOIN M_SgaMst ON OdrCod = SgaCod"
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
+ " WHERE OdrComNum = :m AND OdrStt = 'OE' AND OdrStkStt = 'OE'"
+ " AND OdrInsYon = '3'"
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
+ " AND OdrGrpSub = 0"
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
+ " AND (OdrCod IS NOT NULL AND OdrCod <> '-')"
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
+ " AND SgaNtiNon = 'Y'"
+ " AND (SgaHgnCod IS NOT NULL AND SgaHgnCod <> ' ')"
+ " GROUP BY SgaKorNam, SgaHthNon, SgaGudNon, SgaCarNon, SgaWrkNon, SgaGenFor, SgaGenNon"
+ " ORDER BY SgaKorNam",
("m", comNum));
/// <summary>
/// 특수 계산코드 2종(M_DtlMst SGACFG 의 Use_SgaCA1/Use_SgaCA2, DtlCodVal 의 첫 '/' 조각 —
/// clsCommonLib.vb:3946-3999). 환자 계산코드가 여기 맞으면 레거시 비급여처방은
/// SELECT 에 없는 컬럼(SgaCA1Non)을 읽어 <b>항상 빈 값</b>이었다 — 해석기가 사유로 만든다.
/// </summary>
public IReadOnlyList<string> SpecialNonInsuredCalCodes()
=> Rows("SELECT DtlCod, DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'SGACFG'")
.Where(r => r.TryGetValue("DtlCod", out var c)
&& c.Trim().ToUpperInvariant() is "USE_SGACA1" or "USE_SGACA2")
.Select(r => (r.TryGetValue("DtlCodVal", out var v) ? v : string.Empty)
.Split('/')[0].Trim())
.Where(v => v.Length > 0)
.ToArray();
/// <summary>
/// 진료과의 그룹과목 행(OCM_진단서전문그룹과목, :11245 쿼리1) — 오늘 기준.
/// 원문은 0행이면 Rows(0) 크래시였다 — 여기는 빈 사전(해석기가 사유로).
/// </summary>
public IReadOnlyDictionary<string, string> GroupSubjectRow(string depCod)
=> depCod.Trim().Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: Row(
"SELECT * FROM (SELECT DepBilCod, DepMedDtl, DtlCodNam FROM M_DepMst"
+ " INNER JOIN M_DtlMst ON DepGrpCod = DtlCod AND DtlTblCod = 'GDPTBL'"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
+ " WHERE TRIM(DepCod) = :d"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte)"
+ " WHERE ROWNUM = 1",
("d", depCod.Trim()));
/// <summary>
/// 코드표 이름 — 레거시 GetDtlCodNam(tbl, cod)(기간 조건 없음, clsCommonLib.vb:7020) 대응.
/// 다중이면 원문 첫 행 — 시작일 최신으로 고정한다.
/// </summary>
public string CodeName(string tableCod, string cod)
=> cod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT DtlCodNam v FROM M_DtlMst"
+ " WHERE DtlTblCod = :t AND DtlCod = :c ORDER BY DtlStrDte DESC) WHERE ROWNUM = 1",
("t", tableCod), ("c", cod.Trim()));
/// <summary>
/// 임신 정보 최신 행(OCM_임신주기, :11845) — 분만예정일(PrgEddDte)·최종월경일(PrgLmpDte)
/// 과 서버 오늘. 갱신일시 최신 행(원문 정렬 그대로).
/// </summary>
public IReadOnlyDictionary<string, string> PregnancyRow(decimal comNum)
=> Row(
"SELECT * FROM (SELECT PrgEddDte, PrgLmpDte, TO_CHAR(SYSDATE, 'YYYYMMDD') AS Today"
+ " FROM O_PrgInf WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 낮병동 신청 구간(OCM_재원일수_낮병동, :8083) — 시작·종료일 두 컬럼.
/// 입원일자_낮병동과 같은 조건이고 결정 규칙(시작일 최신)도 같다.
/// </summary>
public IReadOnlyDictionary<string, string> DayCareRange(decimal comNum)
=> Row(
"SELECT * FROM (SELECT DcpStrDte, DcpEndDte FROM P_ComInf"
+ " INNER JOIN P_DcpInf ON ComChtNum = DcpChtNum"
+ " AND ComAcpDtm BETWEEN DcpStrDte AND DcpEndDte || '2359'"
+ " AND DcpAcpStt = 'A'"
+ " WHERE ComNum = :m AND ComAcpStt <> 'OC' AND ComDayCar = 'Y'"
+ " ORDER BY DcpStrDte DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 로그인 부서의 수간호사 이름(ETC_로그인사용자_병동_수간호사, :15507) —
/// 직급 코드표(PSTCOD)에서 '수간호사'/'간호수선생' 코드를 찾아 그 직급의 재직자.
/// 레거시는 두 쿼리 모두 정렬 없는 Rows(0) — 코드·사용자 코드 순으로 고정하고
/// 한 왕복으로 접었다(레거시도 첫 직급 코드 하나만 쓴다).
/// </summary>
public string WardHeadNurse(string depCod)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT UidNam v FROM M_UidMst"
+ " WHERE TRIM(UidDepCod) = :d"
+ " AND UidPstCod IN (SELECT c FROM (SELECT DtsCod c FROM M_DtsMst"
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
+ " AND DtsDtlCod = 'PSTCOD' AND DtsCodNam IN ('수간호사','간호수선생')"
+ " ORDER BY DtsCod) WHERE ROWNUM = 1)"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
("d", depCod.Trim()));
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)</summary>
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in binds)
{
command.Parameters.Add(new OracleParameter(name, value));
}
var result = command.ExecuteScalar();
return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
}
/// <summary>행 하나를 컬럼명(대문자) → 값 사전으로 — 없으면 빈 사전</summary>
private IReadOnlyDictionary<string, string> Row(string sql, params (string Name, object Value)[] binds)
{
var rows = Rows(sql, binds);
return rows.Count > 0 ? rows[0] : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
}
/// <summary>여러 행 — 각 행은 컬럼명 → Trim 값 사전</summary>
private IReadOnlyList<IReadOnlyDictionary<string, string>> Rows(
string sql, params (string Name, object Value)[] binds)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in binds)
{
command.Parameters.Add(new OracleParameter(name, value));
}
var rows = new List<IReadOnlyDictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
}
rows.Add(row);
}
return rows;
}
#endregion
}
@@ -0,0 +1,288 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>환자 검색 방식 — 레거시 fmPatVisitList 의 검색 유형 네 가지</summary>
public enum PatientSearchKind
{
/// <summary>차트번호 — 레거시는 등호 완전일치다(병원별 zero-pad 후 전달)</summary>
ChartNumber,
/// <summary>성명 — 접두 일치</summary>
Name,
/// <summary>주민번호 — 하이픈을 떼고 접두 일치</summary>
ResidentNumber,
/// <summary>휴대전화 — 부분 일치</summary>
MobilePhone,
}
/// <summary>환자 검색 결과 1건</summary>
public sealed record PatientSummary(
string ChtNum, string Name, string ResNumMasked, string MobilePhone,
string LastVisit, bool Inpatient, bool Shielded, bool Active);
/// <summary>내원 1건</summary>
public sealed record VisitSummary(
decimal ComNum, string Kind, string AcceptedAt, string LeftAt,
string Department, string Doctor, string Insurance, string State)
{
/// <summary>접수일시 읽기 좋게 — 목록에서 12자리 숫자를 사람이 읽으려면 자리를 세어야 한다</summary>
public string AcceptedAtText => Readable(AcceptedAt);
/// <summary>퇴원일시 읽기 좋게. 재원 중이면 빈칸이다</summary>
public string LeftAtText => Readable(LeftAt);
/// <summary>
/// YYYYMMDDHHMM → "2026-08-18 09:30". 길이가 다르면 <b>손대지 않고 그대로</b> 보여 준다 —
/// 잘라서 그럴듯하게 만들면 데이터가 이상한 것을 화면이 감춘다.
/// </summary>
private static string Readable(string stamp)
{
var value = (stamp ?? string.Empty).Trim();
return value.Length == 12
? $"{value[..4]}-{value[4..6]}-{value[6..8]} {value[8..10]}:{value[10..12]}"
: value;
}
}
/// <summary>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
///
/// <b>레거시에서 옮기지 않는 것 셋.</b> 원본(<c>dtPatVisitList.vb:42, 133</c>)은
/// ① <b>바인드 변수를 하나도 쓰지 않는다</b> — 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다.
/// 성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다.
/// ② <b>행 제한이 없다</b>(ROWNUM/FETCH 어디에도 없음) — 성명 한 글자로 전 환자가 클라이언트로 온다.
/// ③ <b>TRIM·UPPER 정규화가 없다</b> — CHAR 고정폭 잔여 공백에 걸린다.
/// 셋 다 이 저장소에서는 <see cref="OracleLegacyFormStore"/> 규범대로 고친다.
/// 와일드카드는 SQL 리터럴이 아니라 <b>파라미터 값</b>에 붙인다.
///
/// <b>컬럼을 다 가져오지 않는다.</b> 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고
/// <c>M_ZipMst</c> 를 조인하는 등 화면 12열을 채우려 크다. 미리보기 환자 선택에 필요한 것은
/// 사람을 특정할 수 있는 최소한이므로 그만 읽는다 — 개인정보는 덜 읽는 쪽이 낫다.
///
/// <b>주민번호는 마스킹된 것만 읽는다.</b> 레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄우고
/// 마스킹을 실제로 쓰는 병원은 셋뿐이다(<c>fmPatVisitList.vb:864-869</c>).
/// 여기서는 사람을 가려낼 수 있으면 충분하므로 <c>UDF_GetMaskedResNum</c> 결과만 가져온다.
/// </summary>
public sealed class PatientVisitStore
{
#region Member Fields
/// <summary>기본 행 상한 — 레거시에는 없다. 사람이 눈으로 고르는 목록이라 이보다 많으면 검색어를 좁혀야 한다</summary>
public const int DefaultLimit = 200;
private readonly string connectionString;
#endregion
#region Constructors
public PatientVisitStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>
/// 환자 검색. 검색어가 비면 아무것도 돌려주지 않는다 —
/// 조건 없는 전 환자 조회는 레거시에서도 사고였고 여기서 되살릴 이유가 없다.
/// </summary>
public List<PatientSummary> Search(PatientSearchKind kind, string term, int max = DefaultLimit)
{
var value = (term ?? string.Empty).Trim();
if (value.Length == 0)
{
return new List<PatientSummary>();
}
// 주민번호는 하이픈을 떼고 본다(레거시 :96-100 과 같다).
// 최소 2글자 — 레거시도 이것만은 제한한다(fmPatVisitList.vb:761-763).
if (kind == PatientSearchKind.ResidentNumber)
{
value = value.Replace("-", string.Empty);
if (value.Length < 2)
{
return new List<PatientSummary>();
}
}
var (where, bound) = kind switch
{
// 레거시와 같이 완전일치. 호출부가 병원 규약대로 자릿수를 맞춰 넘긴다.
PatientSearchKind.ChartNumber => ("TRIM(PatChtNum) = :v", value),
PatientSearchKind.Name => ("PatNam LIKE :v", value + "%"),
PatientSearchKind.ResidentNumber => ("PatResNum LIKE :v", value + "%"),
// 레거시는 양쪽 와일드카드라 인덱스를 못 탄다. 뒤에서 찾는 것이 실사용이라 그대로 두되
// 행 상한이 있으므로 전건 스캔이 화면까지 오지는 않는다.
_ => ("PatMblPhn LIKE :v", "%" + value + "%"),
};
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
// 처음에는 최종내원일시와 재원여부를 <b>파생표 두 개</b>로 붙였다.
// 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로,
// 조건에 맞는 환자가 몇 명이든 <b>내원 테이블 전체</b>를 집계했다 — 실측 5,025ms.
//
// 최종내원일시는 <b>화면에 쓰지도 않았다</b>(목록 열은 차트번호·성명·주민번호·휴대전화·비고).
// 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다.
//
// 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 —
// 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b> 따로 읽는다(아래 FillInpatient).
command.CommandText =
"SELECT * FROM ("
+ " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam,"
+ " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked,"
+ " NVL(PatMblPhn,' ') Mbl,"
+ " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon"
+ " FROM P_PatInf"
+ $" WHERE {where}"
+ " ORDER BY PatNam, PatChtNum"
+ $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("v", bound));
var result = new List<PatientSummary>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new PatientSummary(
reader.GetString(0).Trim(),
reader.GetString(1).Trim(),
reader.GetString(2).Trim(),
reader.GetString(3).Trim(),
// 최종내원일시는 더 이상 읽지 않는다 — 화면에 없는 값이었고 집계가 5초였다
string.Empty,
false,
reader.GetString(4).Trim() == "Y",
reader.GetString(5).Trim() != "N"));
}
reader.Close();
return FillInpatient(connection, result);
}
/// <summary>
/// 돌아온 환자들의 <b>재원 여부만</b> 한 번 더 읽는다 — 비고 배지에 쓴다.
///
/// 검색 SQL 안에서 계산하면 조건에 맞는 환자가 몇 명이든 P_ComInf 전체를 집계한다.
/// 여기서는 행 상한이 이미 걸린 뒤라 <b>차트번호 목록으로 한정</b>해서 읽는다.
/// 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다.
///
/// 차트번호는 <b>바인드로</b> 넘긴다 — 값을 SQL 에 이어 붙이면 레거시의 주입 구멍을 되살린다.
/// </summary>
private static List<PatientSummary> FillInpatient(
OracleConnection connection, List<PatientSummary> patients)
{
if (patients.Count == 0)
{
return patients;
}
using var command = connection.CreateCommand();
command.BindByName = true;
var names = new List<string>();
for (var i = 0; i < patients.Count; i++)
{
var name = "c" + i;
names.Add(":" + name);
command.Parameters.Add(new OracleParameter(name, patients[i].ChtNum));
}
command.CommandText =
"SELECT DISTINCT TRIM(ComChtNum) ChtNum FROM P_ComInf"
+ $" WHERE TRIM(ComChtNum) IN ({string.Join(",", names)})"
+ " AND ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm"
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'";
var inpatients = new HashSet<string>(StringComparer.Ordinal);
using (var reader = command.ExecuteReader())
{
while (reader.Read())
{
inpatients.Add(reader.GetString(0).Trim());
}
}
for (var i = 0; i < patients.Count; i++)
{
if (inpatients.Contains(patients[i].ChtNum))
{
patients[i] = patients[i] with { Inpatient = true };
}
}
return patients;
}
/// <summary>
/// 이 환자의 내원 목록 — 최근 것이 위.
///
/// 레거시 <c>GetComInfo</c> 는 조인 10개로 금액 열까지 채우려 하지만
/// <b>그 여섯 열은 값을 받지 못해 화면에서 늘 비어 있다</b>(fmPatVisitList.vb enum 선언에만 존재).
/// 옮기지 않는다. 여기서는 어느 내원인지 사람이 고를 수 있을 만큼만 읽는다.
///
/// 기간 PK 를 가진 마스터(M_DepMst·M_UidMst·M_InsMst)는 그냥 조인하면 같은 내원이 여러 번 뜬다 —
/// <c>ROW_NUMBER() … Rn = 1</c> 로 접는다(OracleLegacyFormStore.cs:94-100 과 같은 이유).
/// </summary>
public List<VisitSummary> ListVisits(string chtNum, int max = DefaultLimit)
{
var key = (chtNum ?? string.Empty).Trim();
if (key.Length == 0)
{
return new List<VisitSummary>();
}
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"SELECT * FROM ("
+ " SELECT A.ComNum,"
+ " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind,"
+ " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt,"
+ " NVL((SELECT MAX(DepKorNam) KEEP (DENSE_RANK FIRST ORDER BY DepStrDte DESC)"
+ " FROM M_DepMst WHERE TRIM(DepCod) = TRIM(B.CodDepCod)),' ') Dep,"
+ " NVL((SELECT MAX(UidNam) KEEP (DENSE_RANK FIRST ORDER BY UidStrDte DESC)"
+ " FROM M_UidMst WHERE TRIM(UidCod) = TRIM(B.CodDtrCod)),' ') Dtr,"
+ " NVL((SELECT MAX(InsNam) KEEP (DENSE_RANK FIRST ORDER BY InsStrDte DESC)"
+ " FROM M_InsMst WHERE TRIM(InsCod) = TRIM(C.CoiInsCod)),' ') Ins,"
+ " NVL(Stt.DtlCodNam,' ') Stt"
+ " FROM P_ComInf A"
+ " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0"
+ " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0"
// 처음에는 마스터 셋을 ROW_NUMBER 파생표로 접었다. 기간 PK 중복은 해결되지만
// <b>마스터 전체를 정렬</b>한 뒤에야 조인되므로 내원이 한 건인 환자도 5초가 걸렸다(실측).
// 상관 스칼라로 바꾼다 — 내원 행마다 코드 하나를 인덱스로 찾는다.
// MAX(...) KEEP (DENSE_RANK FIRST ORDER BY 시작일 DESC) 가 "가장 최근 것 하나"를
// 정렬 없이 뽑아 주고, 결과는 파생표 방식과 같다(중복도 생기지 않는다).
+ " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt"
// 컬럼에 TRIM 을 씌우면 <b>인덱스를 못 탄다</b> — P_ComInf 전건 스캔이 되어
// 내원이 한 건인 환자도 5초가 걸렸다(마스터 조인을 고친 뒤에도 그대로였다).
// LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로
// TRIM 등호를 <b>함께</b> 둬서 넘친 행을 걸러 낸다.
+ " WHERE A.ComChtNum LIKE :p AND TRIM(A.ComChtNum) = :c"
+ " ORDER BY A.ComAcpDtm DESC"
+ $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("p", key + "%"));
command.Parameters.Add(new OracleParameter("c", key));
LastSql = command.CommandText;
var result = new List<VisitSummary>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
result.Add(new VisitSummary(
reader.GetDecimal(0),
reader.GetString(1).Trim(),
reader.GetString(2).Trim(),
reader.GetString(3).Trim(),
reader.GetString(4).Trim(),
reader.GetString(5).Trim(),
reader.GetString(6).Trim(),
reader.GetString(7).Trim()));
}
return result;
}
/// <summary>마지막으로 실행한 SQL — 진단이 예외를 진단할 때만 쓴다(문법 오류는 문장을 봐야 안다)</summary>
public static string LastSql { get; private set; } = string.Empty;
private OracleConnection Open()
{
var connection = new OracleConnection(connectionString);
connection.Open();
return connection;
}
#endregion
}
@@ -0,0 +1,135 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 이 병원의 개인정보 암호화 설정 — 주민번호를 어느 컬럼에서 읽어야 하는지 결정한다.
/// </summary>
/// <param name="Enabled">HSPCFG/PersonalDataEnc 가 Y 인가</param>
/// <param name="ResidentNumber">그 아래 ResNum 이 Y 인가 — 이것이 켜져야 PatResEnc 를 읽는다</param>
public readonly record struct PersonalDataEncryption(bool Enabled, bool ResidentNumber)
{
/// <summary>주민번호가 암호화되어 있는가 — 두 단이 모두 Y 여야 한다</summary>
public bool ResidentNumberEncrypted => Enabled && ResidentNumber;
}
/// <summary>
/// 주민번호 복호화 — 레거시 <c>dtCommonLib.GetDecResNum</c> (:6960) 을 옮긴 것.
///
/// <b>앱은 키를 갖지 않는다.</b> 복호화는 오라클 패키지 <c>CRYPTO_AES256</c> 가 하고
/// 접근 통제는 DB 권한이다 — 그것이 있어야 할 자리다. 키를 상수로 옮기는 이식은 하지 않는다.
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
///
/// <b>앞 7자리는 평문이다.</b> 8자리부터만 암호화되어 있어서 <c>SUBSTR(...,1,7)</c> 을
/// 그대로 이어 붙인다. 이걸 빠뜨리면 앞자리가 사라진 번호가 나온다.
///
/// <b>값을 캐시하지 않는다.</b> 설정(플래그)은 병원당 고정이라 한 번 읽고 들고 있지만,
/// 복호화 결과는 들고 있지 않는다 — 가장 민감한 값을 메모리에 오래 두지 않는다.
/// </summary>
public sealed class PersonalDataStore
{
#region Member Fields
/// <summary>이 병원만 DAMO 로 바뀌었다(2023-10-14) — dtCommonLib.vb:6979</summary>
private const string DamoHospital = "SCHILDREN";
private readonly string connectionString;
private PersonalDataEncryption? cachedFlags;
#endregion
#region Constructors
public PersonalDataStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>
/// 암호화 설정 — 두 단으로 읽는다(clsCommonLib.vb:5835-5841).
///
/// ① <c>M_DtlMst</c>(HSPCFG/PersonalDataEnc) 의 <c>DtlCodVal</c> 에서 <c>=</c> 앞 조각
/// ② 그것이 Y 일 때만 <c>M_DtsMst</c>(HSPCFG/PersonalDataEnc/ResNum) 의 <c>DtsCodVal</c>
///
/// ② 를 무조건 읽지 않는 것이 중요하다 — 레거시가 ① 이 Y 일 때만 읽으므로
/// ① 이 N 인 병원에서 ② 에 남아 있는 옛 값에 걸리지 않는다.
/// </summary>
public PersonalDataEncryption Flags()
{
if (cachedFlags is { } cached)
{
return cached;
}
var result = new PersonalDataEncryption(false, false);
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var head = Scalar(connection,
"SELECT DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'HSPCFG' AND DtlCod = 'PersonalDataEnc'");
// DtlCodVal 은 "Y=..." 형태일 수 있다 — 레거시 Piece(...,"=",1) 과 같이 앞 조각만 본다
var enabled = Piece(head).Equals("Y", StringComparison.OrdinalIgnoreCase);
var resident = false;
if (enabled)
{
// 컬럼 이름은 DtsTblCod · DtsDtlCod · DtsCod 다(dtCommonLib.vb:1136).
// 처음에 DtsTabCod·DtlCod 로 추측해서 ORA-00904 를 맞았다 — 추측하면 여기서 걸린다.
var detail = Scalar(connection,
"SELECT DtsCodVal FROM M_DtsMst WHERE DtsTblCod = 'HSPCFG'"
+ " AND DtsDtlCod = 'PersonalDataEnc' AND DtsCod = 'ResNum'");
resident = Piece(detail).Equals("Y", StringComparison.OrdinalIgnoreCase);
}
result = new PersonalDataEncryption(enabled, resident);
}
catch
{
// 설정을 못 읽으면 <b>암호화 아님</b>으로 두지 않는다 — 그러면 암호문을 평문처럼 쓴다.
// Enabled=false 는 "PatResNum 을 쓰라"는 뜻이 되므로, 여기서는 호출부가
// 값을 만들지 못하도록 두 단 다 false 로 두고 복호화도 시도하지 않게 한다.
// (호출부는 Flags 를 못 믿을 때 태그를 비우고 사유를 말해야 한다.)
cachedFlags = result;
throw;
}
cachedFlags = result;
return result;
}
/// <summary>
/// 암호문을 평문으로. 빈 값이면 빈 값(레거시 :6966-6968).
///
/// 병원 갈래를 함께 옮긴다 — SCHILDREN 은 UDF 하나로 끝내고 나머지는
/// 앞 7자리 + <c>CRYPTO_AES256.DEC_AES</c> 조립이다.
/// </summary>
public string Decrypt(string? encrypted, string hospitalCode)
{
var value = (encrypted ?? string.Empty).Trim();
if (value.Length == 0)
{
return string.Empty;
}
var sql = string.Equals(hospitalCode, DamoHospital, StringComparison.OrdinalIgnoreCase)
? "SELECT UDF_GETDECRESNUM_damo(:e) FROM DUAL"
: "SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL";
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
command.Parameters.Add(new OracleParameter("e", value));
var decrypted = command.ExecuteScalar();
return decrypted is null or DBNull ? string.Empty : decrypted.ToString()!.Trim();
}
private static string Scalar(OracleConnection connection, string sql)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
var value = command.ExecuteScalar();
return value is null or DBNull ? string.Empty : value.ToString()!.Trim();
}
/// <summary>레거시 <c>Piece(값, "=", 1)</c> — 첫 등호 앞 조각</summary>
private static string Piece(string value)
{
var at = value.IndexOf('=');
return (at < 0 ? value : value[..at]).Trim();
}
#endregion
}
@@ -0,0 +1,109 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 심사 상병 조회 — 레거시 <c>Bkd_상병쿼리</c>(bzDataInterface.vb:14625-14811)의 이식.
/// <see cref="DiagnosisStore"/>(O_OkdInf)와 구조가 같고 표만 <b>B_OkdInf</b>(심사분)다.
/// 병원 분기는 KIMEYE 하나뿐이라 그 병원만 미이식 안내가 필요하다.
/// SQL 은 원문 기계 추출(52줄) — 블록1 UNION 블록2, 주/부 필터는 각 블록의 RN 접기 앞.
/// </summary>
public sealed class ReviewDiagnosisStore
{
#region Member Fields
private readonly string connectionString;
private const string Template = @"
SELECT *
FROM (
SELECT *
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam
, ROW_NUMBER() OVER(PARTITION BY KcdCod ORDER BY KcdStrDte DESC) AS OKD_RN
FROM B_OkdInf
INNER JOIN P_ComInf
ON OkdComNum = ComNum
INNER JOIN M_KcdMst
ON KcdCod = TRIM(OkdKcdCod)
AND OkdKcdSeq = KcdSeq
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
WHERE OkdComNum = :piComNum
AND OkdStt = 'E'
{TYPE}
) OKD
WHERE OKD_RN = 1
UNION
SELECT *
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
, KCD.KcdCod, KCD.KcdElcCod, KCD.KcdKorNam, KCD.KcdEngNam
, ROW_NUMBER() OVER(PARTITION BY KCD.KcdCod ORDER BY KCD.KcdStrDte DESC) AS OKD_RN
FROM (
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam
FROM B_OkdInf
INNER JOIN P_ComInf
ON OkdComNum = ComNum
LEFT JOIN M_KcdMst
ON KcdCod = TRIM(OkdKcdCod)
AND OkdKcdSeq = KcdSeq
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
WHERE OkdComNum = :piComNum
AND OkdStt = 'E'
) OKD
INNER JOIN M_KcdMst KCD
ON KCD.KcdCod = TRIM(OkdKcdCod)
AND OkdKcdSeq = KCD.KcdSeq
WHERE 1=1
AND (OKD.KcdCod IS NULL OR OKD.KcdCod = ' ')
{TYPE}
) OKD
WHERE OKD_RN = 1
)
ORDER BY OkdDspSeq
";
#endregion
#region Constructors
public ReviewDiagnosisStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>이 내원의 심사 상병 행들 — 표시 순서대로. 없으면 빈 목록</summary>
public List<Dictionary<string, string>> Diagnoses(decimal comNum, DiagnosisStore.Scope scope)
{
var filter = scope switch
{
DiagnosisStore.Scope.Main => " AND OkdDspSeq = '1' ",
DiagnosisStore.Scope.Sub => " AND OkdDspSeq <> '1' ",
_ => string.Empty,
};
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = Template.Replace("{TYPE}", filter);
command.Parameters.Add(new OracleParameter("piComNum", comNum));
var rows = new List<Dictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i)
? string.Empty
: reader.GetValue(i).ToString()!.Trim();
}
rows.Add(row);
}
return rows;
}
#endregion
}
+2 -2
View File
@@ -11,11 +11,11 @@ namespace SheetMe.Data.Stores;
/// <c>ORDER BY SdgUpdDtm DESC, SdgKey DESC</c> 로 활성 버전을 고르므로,
/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다.
/// </summary>
internal static class ServerClock
public static class ServerClock
{
#region Types
/// <summary>서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공</summary>
internal readonly record struct Stamp(DateTime Value)
public readonly record struct Stamp(DateTime Value)
{
/// <summary>yyyyMMdd (8자리) — ShtAdpDte 류</summary>
public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+158
View File
@@ -0,0 +1,158 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 싸인·직인·로고 태그가 쓰는 <b>원격 이미지 경로</b> 조회 — 레거시 bzDataInterface.vb 의
/// 싸인 태그 17종에서 기계 추출했다. 태그 본문은 전부 같은 골격이다:
/// 경로를 DB 에서 한 칸 얻고 → 파일서버에서 내려받아 → 로컬 경로를 돌려준다.
///
/// <b>여기서는 경로만 얻는다.</b> 내려받는 계층(clsFileTransfer)은 이 병원 설정에서
/// ActiveX OCX(M_DtlMst 'EMR_FileTransfer' Type=1)라 재현할 수 없다 — 그 판정과
/// 대안(레거시 캐시 사용)은 <see cref="Core.Catalog.SignatureImagePath"/> 에 있다.
///
/// 대부분의 원문이 ORDER BY 없는 Rows(0) 이라 정렬을 명시해 한 행으로 고정했다
/// (담당의 때 정한 규칙). 각 메서드 주석에 근거 줄번호를 남긴다.
/// </summary>
public sealed class SignatureStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public SignatureStore(string connectionString)
{
this.connectionString = connectionString;
}
#endregion
#region Methods
/// <summary>
/// 수술 집도의 싸인(OCM_수술집도의_싸인, :3385) — 최신 수술(OprKey DESC)의 집도의
/// (S_OprTyp 'DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 풀어 UidImgPth.
/// 원문의 OprStt 무필터·SYSDATE 유효기간을 그대로 옮긴다.
/// </summary>
public string SurgeonSignature(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'DTRUIDCOD'"
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 수술 마취의사 싸인(OCM_수술마취의사싸인_수술접수연동, :3235) — 같은 골격에
/// 오더 타입만 'ANEUIDCOD'(부서 조인 없음).
/// </summary>
public string AnesthesiaSignatureBySurgery(decimal comNum)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'ANEUIDCOD'"
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 마취과 의사 싸인(OCM_수술마취의사싸인, :3311) — 환자와 무관하게 <b>마취과 소속</b>
/// 재직자 하나. 개원일 2021-03-01 이후 병원은 청구·그룹 코드로, 그 전은 부서명 LIKE
/// '%마취%' 로 찾는다(원문 주석: "코드로 하기엔 병원마다 다를거같아서").
/// 원문에 ORDER BY 가 없어 임의 행이었다 — 사용자 코드 순 첫 행으로 고정한다.
/// </summary>
public string AnesthesiaDepartmentSignature(bool byCode)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ (byCode
? " AND DepBilCod = '09' AND DepGrpCod = 'ANS'"
: " AND DepKorNam LIKE '%마취%'")
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
/// <summary>
/// 부서 코드로 찾는 의사 싸인 — 영상의학과(ETC_영상의학과의사싸인 :17353, 'XRAY')와
/// 진단검사(ETC_진단검사의사싸인 :17578) 계열의 공통형. 기준일은 작업일(없으면 오늘).
/// 원문 ORDER BY 없음 → 사용자 코드 순 고정.
/// </summary>
public string DepartmentDoctorSignature(string depCod, string day8)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE UidDtrYon = 'Y' AND UidDepCod = :d"
+ " AND :y BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
("d", depCod.Trim()), ("y", day8));
/// <summary>
/// 외출·외박 승인자 싸인(ETC_외출외박_확인간호사싸인 :17683 / _확인원무과싸인 :17765) —
/// 신청 행의 승인자 코드(병동 OutAdmWrdAppUid / 원무 OutAdmOffAppUid)를 오늘 기준
/// 사용자 마스터로 푼다. 원문 두 조회 모두 ORDER BY 없음 → 신청 시작일 최신으로 고정.
/// </summary>
public string OutingApproverSignature(decimal comNum, bool officeApprover)
=> Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM O_OutAdmInf"
+ " INNER JOIN M_UidMst ON UidCod = "
+ (officeApprover ? "OutAdmOffAppUid" : "OutAdmWrdAppUid")
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " WHERE OutAdmComNum = :m"
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm) DESC) WHERE ROWNUM = 1",
("m", comNum));
/// <summary>
/// 병동 수간호사 싸인(ETC_로그인사용자_병동_수간호사_싸인, :15562) — 이름 태그와 같은
/// 조회의 이미지 컬럼. 원문은 부서 코드를 문자열 연결로 넣는다 — 바인드로 정상화.
/// </summary>
public string WardHeadNurseSignature(string depCod)
=> depCod.Trim().Length == 0 ? string.Empty
: Scalar(
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
+ " WHERE TRIM(UidDepCod) = :d"
+ " AND UidPstCod IN (SELECT c FROM (SELECT DtsCod c FROM M_DtsMst"
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
+ " AND DtsDtlCod = 'PSTCOD' AND DtsCodNam IN ('수간호사','간호수선생')"
+ " ORDER BY DtsCod) WHERE ROWNUM = 1)"
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
("d", depCod.Trim()));
/// <summary>
/// 병원 로고(ETC_요양기관명칭_병원로고, :16538) — M_HspMst.HspLgoPth, 무기한 유효
/// (HspEndDte='29991231') 행. 원문은 0행이면 Rows(0) 예외였다 — 여기는 빈 값.
/// </summary>
public string HospitalLogo()
=> Scalar(
"SELECT v FROM (SELECT HspLgoPth v FROM M_HspMst"
+ " WHERE HspEndDte = '29991231' ORDER BY HspCod) WHERE ROWNUM = 1");
/// <summary>
/// 병원 직인(ETC_요양기관명칭_병원직인, :16580) — <b>병원 마스터가 아니라</b>
/// 설정 표(DB_REGISTRY, MnuNam='직인경로')의 SelYon 이다(원문에서 M_HspMst.HspStpPth
/// 조회는 주석 처리돼 있다). SCHILDREN·HSYUIL·JAH 는 즉시 빈 값 — 미이식 병원이다.
/// </summary>
public string HospitalStamp()
=> Scalar(
"SELECT v FROM (SELECT SelYon v FROM DB_REGISTRY"
+ " WHERE MnuNam = '직인경로' ORDER BY UidCod) WHERE ROWNUM = 1");
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열</summary>
private string Scalar(string sql, params (string Name, object Value)[] binds)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
foreach (var (name, value) in binds)
{
command.Parameters.Add(new OracleParameter(name, value));
}
var result = command.ExecuteScalar();
return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
}
#endregion
}
+211
View File
@@ -0,0 +1,211 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 수술 정보 조회 — 레거시 <c>GetOprInfDT</c>(bzDataInterface.vb:13203-13363)의 이식.
///
/// 상병과 달리 <b>병원 분기가 없다</b>(Select Case 0회). 바인드는 내원번호 하나뿐이고
/// 접수일자 조건은 원문에서 이미 주석 처리되어 있다(:sAcpDte — 죽은 코드).
///
/// SQL 은 원문에서 기계 추출했다(133줄 그대로). S_OprInf(수술 신청)를 축으로 수술명·부위·상병·
/// 집도의·마취의를 LISTAGG 로 접는 큰 조회이고, 소비 태그 8종이 전부
/// <b>첫 행에서 컬럼 하나</b>를 집는다 — 그래서 행 목록만 돌려주고 선택은 해석기가 한다.
///
/// 원문의 Catch 는 ErrorMessageBox 를 띄운다 — 여기서는 던진다(해석기가 사유로 바꾼다).
/// 조회 함수가 대화상자를 띄우는 부작용은 옮기지 않는다.
/// </summary>
public sealed class SurgeryStore
{
#region Member Fields
private readonly string connectionString;
/// <summary>원문 그대로의 조회 — 바인드는 :iComNum 하나다</summary>
private const string Query = @"
With
OprInfRec
AS
(
SELECT Opr.*
FROM (
SELECT /* 수술 정보 */
OprKey
, OprDte
, OprChtNum
, OprComNum, OprOrgCom, OprGbnCod, OprStt
, OprRomCod
, OprUseTms, OprStrTms, OprDayNum
, OprActStt
, OprNam
, OprDtrCod, OprDepCod, OprSplCmt
, CASE OprRegion WHEN '1' THEN '오른쪽'
WHEN '2' THEN '왼쪽'
WHEN '3' THEN '양쪽'
ELSE '없음'
END AS OprRegionName
, OprPatEtc
, OPR.OprUpdDtf
/* 인적 정보 */
, PatNam
, PatSexTyp
, ComPatTyp
, SUBSTR(ComAcpDtm, 1, 8) AS ComAcpDte
/* 관련 정보 */
, PRGSTSDTL.DtlCodNam AS OprActFlg
, UIDOPRDTR.UidNam AS OprDtrNam /* 집도의 명 */
, UIDCODDTR.UidNam AS CodDtrNam /* 주치의 명 */
, ROW_NUMBER() OVER (PARTITION BY OprKey ORDER BY CodStrDtm DESC ) AS RN
FROM S_OprInf OPR
LEFT JOIN P_PatInf
ON PatChtNum = OprChtNum
/* 수술정보의 내원정보 */
LEFT JOIN P_ComInf
ON OprComNum = ComNum
LEFT JOIN P_CodInf
ON CodComNum = ComNum
AND CASE WHEN ComPatTyp = 'I' THEN OprDte ||'2359'
ELSE CodStrDtm
END BETWEEN CodStrDtm AND CodEndDtm
LEFT JOIN M_DtlMst PRGSTSDTL
ON PRGSTSDTL.DtlTblCod = 'OPRPRGSTS'
AND PRGSTSDTL.DtlCod = OprActStt
/* 집도의 */
LEFT JOIN M_UidMst UIDOPRDTR
ON UIDOPRDTR.UidCod = OprDtrCod
AND OprDte BETWEEN UIDOPRDTR.UidStrDte AND UIDOPRDTR.UidEndDte
/* 주치의 */
LEFT JOIN M_UidMst UIDCODDTR
ON UIDCODDTR.UidCod = CodDtrCod
AND OprDte BETWEEN UIDCODDTR.UidStrDte AND UIDCODDTR.UidEndDte
WHERE 1 = 1
AND OprStt = 'E'
-- AND OprDte = :sAcpDte
AND OprComNum = :iComNum
-- AND OprDepCod IN ('0100','0101','0200','0201','0300','1601','1900','2100','2301','7001','0400','0500','0501','0550','0600','0601','0900','0901','1000','1100','1300','2601','0800','1400','1500','1600','5500')
) OPR
WHERE RN = 1
)
,
OprTypRec
AS
(
SELECT OprTypKey, DX, OPNAME
, SUBSTR(ANECOD, 1, LENGTH(ANECOD)-1) AS ANECOD
, SUBSTR(ANECODNAM, 1, LENGTH(ANECODNAM)-1) AS ANECODNAM
, SUBSTR(ANEUIDCOD, 1, LENGTH(ANEUIDCOD)-1) AS ANEUIDCOD
, SUBSTR(OKDNAM, 1, LENGTH(OKDNAM)) AS OKDNAM
, SUBSTR(OPRCODNAM, 1, LENGTH(OPRCODNAM)) AS OPRCODNAM
FROM (
SELECT OprKey AS OprTypKey
, MAX(DX.OprCodNam) AS DX
, MAX(OPNAME.OprCodNam) AS OPNAME
, regexp_replace(LISTAGG(ANECOD.OprCod || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECOD
, regexp_replace(LISTAGG(ANECOD.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANECOD.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANECODNAM
, regexp_replace(LISTAGG(ANEUID.OprCodNam || ',') WITHIN GROUP ( ORDER BY ANEUID.OprTypKey ), '([^,]+)(,\1)+', '\1') AS ANEUIDCOD
, MIN(OKDNAM.OprCodNam) AS OKDNAM
, MIN(OPRCODNAM.OprCodNam) AS OPRCODNAM
FROM OprInfRec
LEFT JOIN S_OprTyp DX
ON OprKey = DX.OprKeyNum
AND DX.OprTyp = 'DX'
LEFT JOIN S_OprTyp OPNAME
ON OprKey = OPNAME.OprKeyNum
AND OPNAME.OprTyp = 'OPNAME'
LEFT JOIN S_OprTyp ANECOD
ON OprKey = ANECOD.OprKeyNum
AND ANECOD.OprTyp = 'ANECOD'
LEFT JOIN S_OprTyp ANEUID
ON OprKey = ANEUID.OprKeyNum
AND ANEUID.OprTyp = 'ANEUIDCOD'
LEFT JOIN S_OprTyp OKDNAM
ON OprKey = OKDNAM.OprKeyNum
AND OKDNAM.OprTyp = 'OKD'
LEFT JOIN S_OprTyp OPRCODNAM
ON OprKey = OPRCODNAM.OprKeyNum
AND OPRCODNAM.OprTyp = 'OPRCOD'
AND ROWNUM = 1
WHERE 1 = 1
GROUP BY OprKey
)
)
,
OprLstRec
AS
(
SELECT *
FROM OprInfRec
LEFT JOIN OprTypRec
ON OprKey = OprTypKey
)
--SELECT * FROM OprInfRec
--SELECT * FROM OprTypRec
SELECT * FROM OprLstRec
ORDER BY OprComNum, OprDte DESC, OprDayNum ASC
";
#endregion
#region Constructors
public SurgeryStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>이 내원의 수술 행들 — 없으면 빈 목록</summary>
public List<Dictionary<string, string>> Surgeries(decimal comNum)
=> Surgeries(comNum, mainDiagnosisOnly: false);
/// <summary>
/// <paramref name="mainDiagnosisOnly"/> 가 true 면 상병 집계를 주상병(OPRVAR1='0')로
/// 한정한다 — 레거시 <c>GetOprInfDT_OKDMain</c>(bzDataInterface.vb:13364)과
/// <c>GetOprInfDT</c> 의 유일한 실질 차이가 이 조인 조건 한 줄이다
/// (OKDMain 판의 수술부위·비고 컬럼 부재와 정렬 약화는 옮기지 않는다 —
/// 그 판으로 부위 태그를 부르면 레거시도 컬럼 예외였다).
/// </summary>
public List<Dictionary<string, string>> Surgeries(decimal comNum, bool mainDiagnosisOnly)
{
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
var sql = mainDiagnosisOnly
? Query.Replace("AND OKDNAM.OprTyp = 'OKD'",
"AND OKDNAM.OprTyp = 'OKD' AND OKDNAM.OPRVAR1 = '0'")
: Query;
if (mainDiagnosisOnly && !sql.Contains("OPRVAR1", StringComparison.Ordinal))
{
// 치환이 빗나가면 주상병 필터 없는 값이 조용히 나간다 — 빈 값보다 나쁘다
throw new InvalidOperationException("주상병 필터 치환이 원문과 맞지 않습니다");
}
command.CommandText = sql;
command.Parameters.Add(new OracleParameter("iComNum", comNum));
var rows = new List<Dictionary<string, string>>();
using var reader = command.ExecuteReader();
while (reader.Read())
{
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
for (var i = 0; i < reader.FieldCount; i++)
{
row[reader.GetName(i)] = reader.IsDBNull(i)
? string.Empty
: reader.GetValue(i).ToString()!.Trim();
}
rows.Add(row);
}
return rows;
}
#endregion
}
+115
View File
@@ -0,0 +1,115 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>태그 값을 만드는 데 필요한 재료 — 서버 시각과 로그인 사용자</summary>
public readonly record struct TagPreviewContext(
string Minute12, string UidCod, string UidNam, string MobilePhone, string OfficePhone)
{
public static readonly TagPreviewContext Empty =
new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
public bool HasClock => Minute12.Length >= 12;
}
/// <summary>
/// 태그 값 미리보기의 재료를 한 번에 읽는다.
///
/// <b>왜 따로 두는가.</b> 서식생성기에서 값이 나오는 태그는 서버 시각 계열과 로그인 사용자 계열뿐이다
/// (나머지는 환자·내원 문맥이 있어야 하고 여기에는 그 문맥이 없다).
/// 그래서 조회는 <b>왕복 2회로 끝난다</b> — 시각 1회, 사용자 1행 1회.
/// 태그를 고를 때마다 DB 를 치면 목록을 화살표로 훑는 동안 초당 몇 번씩 왕복하게 되므로
/// 호출부가 결과를 세션 동안 들고 있게 만든다.
///
/// 고정 SQL 만 쓴다. 사용자 입력이 SQL 에 닿는 경로를 만들지 않는다 —
/// 이 창은 임의 쿼리를 돌리는 곳이 아니다.
/// </summary>
public sealed class TagPreviewStore
{
#region Member Fields
/// <summary>미리보기가 편집을 막으면 안 된다 — 늦으면 값 없이 넘어간다</summary>
private const int TimeoutSeconds = 5;
private readonly string connectionString;
#endregion
#region Constructors
public TagPreviewStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Properties
/// <summary>접속 정보가 없으면 미리보기 줄 자체를 감춘다</summary>
public bool CanUseDb => connectionString.Length > 0;
#endregion
#region Methods
/// <summary>
/// 재료를 한 번에 읽는다. 실패는 삼킨다 —
/// 미리보기가 안 되는 것과 태그를 못 고르는 것은 다른 일이다.
/// </summary>
public TagPreviewContext Read(string uidCod, string uidNam)
{
if (!CanUseDb)
{
return TagPreviewContext.Empty;
}
try
{
using var connection = new OracleConnection(connectionString);
connection.Open();
var stamp = ServerClock.Read(connection, null);
var (mobile, office) = ReadPhones(connection, uidCod);
return new TagPreviewContext(stamp.Minute12, uidCod, uidNam, mobile, office);
}
catch (OracleException)
{
return TagPreviewContext.Empty;
}
catch (InvalidOperationException)
{
return TagPreviewContext.Empty;
}
}
/// <summary>
/// 연락처 두 칸 — 세션에 없는 것은 이것뿐이라 이 한 줄만 더 읽는다.
///
/// <b>유효기간으로 고른다.</b> 레거시(GetUidMst, clsCommonLib.vb:10636)는
/// <c>:일자 BETWEEN UidStrDte AND UidEndDte</c> 로 행을 고르고,
/// 0행이면 코드만으로 다시 찾는다(:10641 폴백). 전에는 조건 없이 첫 행을 집어서
/// 이력이 여러 행인 사용자에서 <b>옛 연락처</b>가 나올 수 있었다.
/// 판정 일자는 레거시가 로그인 시점(GetAdpDtm 앞 8자)인데 여기는 서버 오늘이다 —
/// 자정을 넘겨 계속 켜 둔 세션에서만 갈리는 차이라 받아들이고 기록해 둔다.
/// </summary>
private static (string Mobile, string Office) ReadPhones(OracleConnection connection, string uidCod)
{
if (uidCod.Length == 0)
{
return (string.Empty, string.Empty);
}
var valid = ReadPhoneRow(connection,
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UIDSTRDTE AND UIDENDDTE", uidCod);
return valid ?? ReadPhoneRow(connection,
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid", uidCod)
?? (string.Empty, string.Empty);
}
private static (string Mobile, string Office)? ReadPhoneRow(
OracleConnection connection, string sql, string uidCod)
{
using var command = connection.CreateCommand();
command.CommandText = sql;
command.CommandTimeout = TimeoutSeconds;
command.Parameters.Add(new OracleParameter("uid", uidCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return null;
}
return (reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1));
}
#endregion
}
+37 -3
View File
@@ -9,7 +9,25 @@ namespace SheetMe.Data.Stores;
public sealed record HisUser(
string UidCod, string UidNam,
string DepCod, string DepNam,
string HspCod, string HspNam)
string HspCod, string HspNam,
/// <summary>의사 여부(UidDtrYon=Y) — OCM_User진료과 계열이 로그인 사용자 분기에 쓴다</summary>
string DoctorYn = "",
/// <summary>부서 공식 명칭(DepOfcNam) — 개원일 조건이 걸리는 병원에서 한글명 대신 쓴다</summary>
string DepOfficialName = "",
/// <summary>의사면허번호(UidLicNum) — ETC_로그인_의사면허번호</summary>
string LicenseNumber = "",
/// <summary>전문의번호(UidSpcLic) — ETC_로그인_전문의번호</summary>
string SpecialistNumber = "",
/// <summary>직종명(M_DtsMst JOBCOD 의 DtsCodNam) — ETC_로그인_직종</summary>
string JobName = "",
/// <summary>부서 전화번호(M_DepMst.DepTelNum) — ETC_접속유저_부서번호(이름과 달리 전화번호다)</summary>
string DepPhone = "",
/// <summary>영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래</summary>
string EnglishName = "",
/// <summary>세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호</summary>
string DetailSpecialistNumber = "",
/// <summary>싸인 이미지 원격 경로(UidImgPth) — 로그인 사용자 싸인 계열</summary>
string SignaturePath = "")
{
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
@@ -58,15 +76,27 @@ public sealed class UserContextStore
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
// 레거시와 두 가지가 <b>의도적으로</b> 다르다(워크플로 검증이 짚었고, 받아들이기로 했다):
// · 유효기간 판정 일자 — 레거시는 로그인 시점(GetAdpDtm 앞 8자, clsCommonLib.vb:10622),
// 여기는 SYSDATE. 자정을 넘겨 계속 켜 둔 세션에서만 갈린다.
// · Trim — 레거시는 UidCod/UidNam 을 무가공으로 쓰고(bzDataInterface.vb:16713, 16720)
// 여기는 걷는다. CHAR 잔여 공백이 종이에 찍히는 쪽이 더 이상하다고 판단했다.
command.CommandText =
"SELECT u.UidCod, NVL(u.UidNam,' '), " +
" NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " +
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' ') " +
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " +
" NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " +
" NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " +
" NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' '), " +
" NVL(u.UidImgPth,' ') " +
"FROM M_UidMst u " +
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
"LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " +
// 직종 조인은 레거시 그대로 DtsDtlCod 만으로 건다(bzDataInterface.vb:17944 — DtsTblCod 조건이 없다)
"LEFT JOIN M_DtsMst j ON j.DtsDtlCod = 'JOBCOD' AND j.DtsCod = u.UidJobCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN j.DtsStrDte AND j.DtsExpDte " +
"WHERE u.UidCod = :u " +
"AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
@@ -79,7 +109,11 @@ public sealed class UserContextStore
return new HisUser(
reader.GetString(0).Trim(), reader.GetString(1).Trim(),
reader.GetString(2).Trim(), reader.GetString(3).Trim(),
reader.GetString(4).Trim(), reader.GetString(5).Trim());
reader.GetString(4).Trim(), reader.GetString(5).Trim(),
reader.GetString(6).Trim(), reader.GetString(7).Trim(),
reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(),
reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim(),
reader.GetString(14).Trim());
}
#endregion
}
+85
View File
@@ -0,0 +1,85 @@
using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
/// <summary>
/// 바이탈(측정치) 조회 — 레거시 <c>OCM_키/몸무게/체온/혈압/맥박/호흡/SPO2(±_LAST)</c> 계열
/// (bzDataInterface.vb:11508-13046)의 공통 조회.
///
/// 전부 같은 틀이다:
/// <c>E_EmdInf_VITAL ⨝ E_EmrInf(EmrKey=EmdEmrKey, 삭제 제외)</c> 를 내원으로 거르고
/// 해당 컬럼이 비어 있지 않은 행을 측정일시 순으로 한 건 집는다 —
/// 무인자는 <b>첫 측정</b>(ASC), _LAST 는 <b>마지막 측정</b>(DESC).
///
/// SELECT 식·널 필터·정렬까지 태그마다 본문에서 확인한 대로 호출부(해석기)가 지정한다.
/// 식과 컬럼명은 전부 <b>코드 상수</b>에서만 온다 — 사용자 입력이 닿는 경로가 아니다.
/// </summary>
public sealed class VitalStore
{
#region Member Fields
private readonly string connectionString;
#endregion
#region Constructors
public VitalStore(string connectionString) => this.connectionString = connectionString;
#endregion
#region Methods
/// <summary>측정치 한 건 — 없으면 빈 문자열</summary>
/// <param name="selectExpr">SELECT 식(별칭 포함) — 예: "EmdWeight" / "EmdBps || '/' || EmdBpd AS BP"</param>
/// <param name="pick">결과에서 집을 컬럼(별칭)</param>
/// <param name="notNullColumns">비어 있으면 안 되는 컬럼들(레거시 필터 그대로)</param>
/// <param name="orderBy">정렬 절 — 예: "EmdDte ASC, EmdTime ASC"</param>
public string One(decimal comNum, string selectExpr, string pick,
string[] notNullColumns, string orderBy)
=> Fetch("E_EmdInf_VITAL", "EmrComNum = :k", comNum, selectExpr, pick, notNullColumns, orderBy);
/// <summary>
/// BST 는 표가 다르다(E_EmdInf_BST, bzDataInterface.vb:12042-12080·12710-12747) —
/// 같은 조인 틀에 표만 바뀐다.
/// </summary>
public string OneBst(decimal comNum, string orderBy)
=> Fetch("E_EmdInf_BST", "EmrComNum = :k", comNum, "EmdBst", "EmdBst",
new[] { "EmdBst" }, orderBy);
/// <summary>
/// BMI·표준체중·조정체중은 내원이 아니라 <b>차트 전체</b>에서 최신 측정을 집는다
/// (bzDataInterface.vb:12159-12333 — EmrChtNum 조건, 주석 처리된 EmrComNum 이 그 흔적).
/// </summary>
public string OneByChart(string chtNum, string selectExpr, string pick,
string[] notNullColumns, string orderBy)
=> Fetch("E_EmdInf_VITAL", "EmrChtNum = RPAD(:k, 10)", chtNum, selectExpr, pick,
notNullColumns, orderBy);
private string Fetch(string table, string where, object key, string selectExpr, string pick,
string[] notNullColumns, string orderBy)
{
var filters = string.Concat(notNullColumns.Select(c =>
$" AND ({c} IS NOT NULL AND {c} <> ' ') "));
var sql =
"SELECT * FROM ("
+ $" SELECT {selectExpr}"
+ $" FROM {table}"
+ " INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey"
+ " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D')"
+ $" WHERE {where}"
+ filters
+ $" ORDER BY {orderBy}"
+ " ) WHERE ROWNUM = 1";
using var connection = new OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
command.Parameters.Add(new OracleParameter("k", key));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return string.Empty;
}
var at = reader.GetOrdinal(pick);
return reader.IsDBNull(at) ? string.Empty : reader.GetValue(at).ToString()!.Trim();
}
#endregion
}
+35
View File
@@ -41,6 +41,7 @@ public partial class App : Application
if (Services.StartupArguments.IsDiagnostic(e.Args))
{
diagnosticMode = true;
Services.StartupArguments.DiagnosticMode = true;
// 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단
// (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저
// Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 —
@@ -77,6 +78,8 @@ public partial class App : Application
if (sender is Window window)
{
Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight);
// 모달이면 뒤를 어둡게 — 여기서 한 번 걸어야 새 대화상자에서 빠지지 않는다
Services.ModalScrim.Attach(window);
}
}));
@@ -155,6 +158,16 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
}
if (args.Length >= 3 && args[0] == "--db-patient")
{
return Diagnostics.DbSmoke.RunPatientProbe(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-modify-smoke")
{
return Diagnostics.DbSmoke.RunModifySmoke(args[1], args[2]);
}
if (args.Length >= 3 && args[0] == "--db-save-smoke")
{
return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]);
@@ -210,6 +223,12 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
}
// 싸인·이미지 태그의 전송 수단을 확정한다(값은 안 찍는다)
if (args.Length >= 2 && args[0] == "--db-filecfg")
{
return Diagnostics.DbSmoke.RunFileTransferConfig(args[1]);
}
if (args.Length >= 3 && args[0] == "--db-word-smoke")
{
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
@@ -275,11 +294,27 @@ public partial class App : Application
return Diagnostics.SnapShots.Run(args[1]);
}
if (args.Length >= 1 && args[0] == "--modal-check")
{
return Diagnostics.ModalCheck.Run();
}
if (args.Length >= 1 && args[0] == "--maxrect")
{
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
}
if (args.Length >= 1 && args[0] == "--scale-budget")
{
var count = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 1067;
return Diagnostics.ScaleBudget.Run(args.Length >= 2 ? args[1] : null, count);
}
if (args.Length >= 1 && args[0] == "--cleartype")
{
return Diagnostics.ClearTypeCheck.Run();
}
// 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서
// 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로
// 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유).
@@ -40,26 +40,88 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
var step = shift ? designer.Snap.GridSize * 2 : 1;
var handled = true;
switch (e.Key)
// Alt 조합은 e.Key 로 오지 않는다 — WPF 가 Key.System 으로 싸서 넘기고 실제 키는 SystemKey 에 있다.
// e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데, 그 실패는 "눌러도 아무 일이 없다"로만 보인다.
var key = e.Key == Key.System ? e.SystemKey : e.Key;
switch (key)
{
// Ctrl+방향키는 <b>크기</b>다 — 위치에는 미세 조정 수단이 있는데 크기만 없었다.
// 3px 늘리려고 300px 떨어진 인스펙터 W 칸까지 마우스로 왕복하던 것을 없앤다.
// Shift 를 함께 누르면 위치와 같은 큰 걸음(step)을 쓴다.
case Key.Left when ctrl: designer.ResizeSelection(-step, 0); break;
case Key.Right when ctrl: designer.ResizeSelection(step, 0); break;
case Key.Up when ctrl: designer.ResizeSelection(0, -step); break;
case Key.Down when ctrl: designer.ResizeSelection(0, step); break;
case Key.Left: designer.NudgeSelection(-step, 0); break;
case Key.Right: designer.NudgeSelection(step, 0); break;
case Key.Up: designer.NudgeSelection(0, -step); break;
case Key.Down: designer.NudgeSelection(0, step); break;
case Key.Delete: designer.DeleteSelection(); break;
case Key.Escape: designer.Interaction.Cancel(); break;
// 루프를 키보드로 닫는 세 키. 여기까지는 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"가
// 마우스 없이 한 바퀴도 돌지 않았다 — 선택을 옮길 키가 하나도 없었다.
// PreviewKeyDown 이라 WPF 포커스 관리자보다 먼저 Tab 을 본다(안 그러면 캔버스 밖으로 나간다).
case Key.Tab: designer.SelectSibling(backward: shift); break;
case Key.Enter: handled = designer.EnterSelection(); break;
case Key.F2: handled = designer.EnterSelection(); break;
case Key.A when ctrl: designer.SelectAllOnActivePage(); break;
case Key.C when ctrl: designer.CopySelection(); break;
case Key.X when ctrl: designer.CutSelection(); break;
case Key.V when ctrl: designer.Paste(); break;
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
// Ctrl+Shift+Z 는 <b>다시 실행</b>이다. 이 갈래가 없어서 전에는 되돌리기가 한 번 더 됐다 —
// 방향이 반대인 줄 모르는 사용자는 "안 돌아오네" 하며 두세 번 더 눌러 편집분을 통째로 날린다.
// 실패가 조용히 성공하므로(되돌리기는 언제나 성공한다) 경고도 없다.
// 바로 아래 Key.G 가 이미 같은 짝을 가르고 있는데 Z 만 빠져 있었다.
case Key.Z when ctrl && shift: designer.Undo.Redo(); break;
case Key.Z when ctrl: designer.Undo.Undo(); break;
case Key.Y when ctrl: designer.Undo.Redo(); break;
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
case Key.G when ctrl: designer.GroupSelection(); break;
// 선택 확장 — 정렬·간격 명령이 전제하는 '올바른 선택'을 만드는 수단.
// 마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 오고(실측 침입자 중앙값 7개),
// 그래서 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 붙었다.
case Key.D1 when alt: designer.SelectSame(DesignerViewModel.SameKind.Row); break;
case Key.D2 when alt: designer.SelectSame(DesignerViewModel.SameKind.Column); break;
case Key.D3 when alt: designer.SelectSame(DesignerViewModel.SameKind.Type); break;
case Key.D4 when alt: designer.SelectSame(DesignerViewModel.SameKind.Size); break;
// 잠금·숨김 — 상태와 레이어 패널의 자물쇠는 있었는데 캔버스에서 켤 방법이 없었다.
// 배경 괘선 40개를 잠그려면 레이어 트리에서 40번 눌러야 했다.
case Key.L when ctrl: designer.ToggleLockSelection(); break;
case Key.H when ctrl && shift: designer.ToggleHiddenSelection(); break;
// 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
case Key.A when alt: designer.Align("Left"); break;
case Key.D when alt: designer.Align("Right"); break;
case Key.W when alt && !shift: designer.Align("Top"); break;
case Key.S when alt && !shift: designer.Align("Bottom"); break;
case Key.H when alt && !shift: designer.Align("CenterH"); break;
case Key.V when alt && !shift: designer.Align("CenterV"); break;
// 균등 간격 — 3개 이상일 때만 뜻이 있다
case Key.H when alt && shift: designer.AdjustSpacing("HorizEqual"); break;
case Key.V when alt && shift: designer.AdjustSpacing("VertEqual"); break;
// 같은 크기로
case Key.W when alt && shift: designer.SizeToControl("Width"); break;
case Key.S when alt && shift: designer.SizeToControl("Height"); break;
// 간격 넓게/좁게 — 세로가 기본(기록지는 줄이 세로로 쌓인다), Shift 로 가로
case Key.OemCloseBrackets when alt && !shift: designer.AdjustSpacing("VertIncrease"); break;
case Key.OemOpenBrackets when alt && !shift: designer.AdjustSpacing("VertDecrease"); break;
case Key.OemCloseBrackets when alt && shift: designer.AdjustSpacing("HorizIncrease"); break;
case Key.OemOpenBrackets when alt && shift: designer.AdjustSpacing("HorizDecrease"); break;
default: handled = false; break;
}
@@ -21,6 +21,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
AssociatedObject.LostMouseCapture += OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown;
}
protected override void OnDetaching()
@@ -29,6 +30,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
AssociatedObject.LostMouseCapture -= OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown;
base.OnDetaching();
}
@@ -54,6 +56,44 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
e.Handled = true;
}
/// <summary>
/// 우클릭 — 메뉴를 열기 <b>전에</b> 커서 아래 컨트롤을 선택한다.
///
/// 전에는 우클릭이 선택을 전혀 건드리지 않았다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를
/// 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 <b>A 를 지웠다</b>. 선택이 비어 있으면
/// 메뉴 항목이 전부 살아 있는 채로 아무 일도 하지 않았다.
/// 윈도우의 거의 모든 프로그램이 지키는 규약이라, 사용자는 메뉴가 가리키는 대상을
/// 확인할 생각조차 하지 않는다 — 20항목짜리 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.
///
/// <b>이미 선택 안에 있으면 선택을 유지한다</b> — 다중 선택을 우클릭해서 정렬하는 흐름을 깨면 안 된다.
/// <c>e.Handled</c> 는 두지 않는다. 그대로 흘려보내야 기존 ContextMenu 가 평소처럼 열린다.
/// </summary>
private void OnRightButtonDown(object sender, MouseButtonEventArgs e)
{
if (Designer is not { } designer || designer.IsTabOrderMode)
{
return; // 탭순서 편집 중에는 클릭이 순번 지정이라 선택을 건드리지 않는다
}
FocusCanvasHost();
var world = e.GetPosition(AssociatedObject);
if (designer.HitTestControl(world) is { } hit)
{
if (!hit.IsSelected)
{
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
}
return;
}
// 빈 곳이면 선택을 푼다 — 다만 <b>잠긴/숨긴 것 위</b>는 빈 곳이 아니다.
// HitTestControl 이 그것들을 후보에서 아예 빼기 때문에(DesignerViewModel) 여기까지 오는데,
// 잠긴 배경 괘선 위에서 우클릭할 때마다 선택이 사라지면 "왜 자꾸 풀리지"가 된다.
if (!designer.AnyControlAt(world))
{
designer.Selection.Clear();
}
}
private void OnMouseMove(object sender, MouseEventArgs e)
{
if (Designer is not { } designer)
@@ -138,6 +138,58 @@ public static class LayerListBehavior
{
syncing.Remove(list);
}
RevealPrimary(list, designer);
}
/// <summary>
/// 캔버스에서 고른 것을 목록이 <b>따라오게</b> 한다 — 조상을 펴고 그 행으로 스크롤한다.
///
/// <b>왜 필요한가.</b> 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면
/// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어
/// <b>행 자체가 만들어지지 않았거나</b>. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다.
/// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
///
/// <b>펴기는 한 번만 시도한다.</b> 편 결과로 목록이 다시 만들어지면 재구성 이벤트가
/// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다.
/// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 —
/// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다.
/// </summary>
private static void RevealPrimary(ListBox list, DesignerViewModel designer)
{
if (designer.Selection.Primary is not { } primary)
{
return;
}
if (RowOf(list, primary) is { } row)
{
list.ScrollIntoView(row);
return;
}
if (!designer.ExpandLayerPathTo(primary))
{
return; // 펼 것이 없는데도 행이 없다 = 필터에 걸린 것이다
}
// 재구성은 비동기로 도는 경로가 있다 — 한 틱 뒤에 다시 찾아본다
list.Dispatcher.BeginInvoke(new Action(() =>
{
if (RowOf(list, primary) is { } revealed)
{
list.ScrollIntoView(revealed);
}
}), System.Windows.Threading.DispatcherPriority.Background);
}
/// <summary>이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다)</summary>
private static object? RowOf(ListBox list, ControlViewModel control)
{
foreach (var row in list.Items)
{
if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control))
{
return row;
}
}
return null;
}
/// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
@@ -0,0 +1,110 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Behaviors;
/// <summary>
/// 태그 행 인라인 완성 — 타이핑하면 목록이 뜨고 ↑↓/Enter/Esc 로 고른다.
///
/// <b>왜 이 화면인가.</b> 태그를 붙이는 일은 하루 수십~수백 번이고 운영 사용이 8,032건이다.
/// 그런데 칩에 없는 태그는 이름을 16자 외워 치거나 1080×700 모달을 열어야 했고,
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다</b>.
///
/// <b>새 패턴을 만들지 않는다.</b> 이 앱에는 이미 같은 것이 있다 —
/// QueryEditorWindow 의 CompletionPopup(StaysOpen=True, ↑↓/Enter/Esc, 회귀 주석까지).
/// 거기서 배운 것을 그대로 쓴다: <b>Popup 을 StaysOpen=False 로 두면 마우스를 캡처해
/// 항목 클릭을 '바깥 클릭'으로 먹어 버린다</b>(그래서 눌러도 입력이 안 되고 사라졌다).
///
/// 부착 방식이 첨부 속성인 이유: 태그 행은 DataTemplate 안에 있어 x:Name 으로 잡을 수 없다.
/// </summary>
public static class TagCompletionBehavior
{
#region Member Fields
public static readonly DependencyProperty EnabledProperty =
DependencyProperty.RegisterAttached("Enabled", typeof(bool), typeof(TagCompletionBehavior),
new PropertyMetadata(false, OnEnabledChanged));
#endregion
#region Methods
public static void SetEnabled(DependencyObject target, bool value) => target.SetValue(EnabledProperty, value);
public static bool GetEnabled(DependencyObject target) => (bool)target.GetValue(EnabledProperty);
private static void OnEnabledChanged(DependencyObject target, DependencyPropertyChangedEventArgs e)
{
if (target is not TextBox box)
{
return;
}
if ((bool)e.NewValue)
{
box.TextChanged += OnTextChanged;
box.PreviewKeyDown += OnPreviewKeyDown;
box.LostKeyboardFocus += OnLostFocus;
return;
}
box.TextChanged -= OnTextChanged;
box.PreviewKeyDown -= OnPreviewKeyDown;
box.LostKeyboardFocus -= OnLostFocus;
}
private static TagPickerRowViewModel? RowOf(object sender)
=> (sender as FrameworkElement)?.DataContext as TagPickerRowViewModel;
private static void OnTextChanged(object sender, TextChangedEventArgs e)
{
if (RowOf(sender) is not { } row || sender is not TextBox box)
{
return;
}
// 코드가 값을 넣은 경우(칩 클릭·피커 확정)에는 목록을 열지 않는다 —
// 방금 고른 것 위에 목록이 덮이면 다 골랐는데 가리는 셈이다.
if (!box.IsKeyboardFocusWithin)
{
return;
}
row.BeginTyping();
row.RefreshCompletions(box.Text);
}
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (RowOf(sender) is not { } row)
{
return;
}
switch (e.Key)
{
case Key.Down when row.ShowCompletions:
row.MoveCompletion(1);
e.Handled = true;
break;
case Key.Up when row.ShowCompletions:
row.MoveCompletion(-1);
e.Handled = true;
break;
case Key.Enter when row.ShowCompletions:
// 확정하면 Enter 를 여기서 먹는다 — 안 그러면 InspectorFieldBehavior 가
// 같은 Enter 로 옛 텍스트를 한 번 더 커밋한다.
e.Handled = row.CommitCompletion();
if (e.Handled && sender is TextBox committed)
{
committed.Text = row.ValueText;
committed.CaretIndex = committed.Text.Length;
}
break;
case Key.Escape when row.ShowCompletions:
row.EndTyping();
e.Handled = true;
break;
default:
break;
}
}
private static void OnLostFocus(object sender, KeyboardFocusChangedEventArgs e)
=> RowOf(sender)?.EndTyping();
#endregion
}
@@ -0,0 +1,25 @@
using System.Globalization;
using System.Windows.Data;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다.
///
/// 값이 갈린 다중 선택에서 <see cref="ViewModels.Inspector.ChoiceRowViewModel.EnsureMixedPlaceholder"/> 가
/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 <b>빈 줄</b>이 하나 보일 뿐이라
/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다.
/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야
/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다.
/// </summary>
public sealed class ChoiceLabelConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
var text = value as string ?? string.Empty;
return text.Length == 0 ? "여러 값" : text;
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
=> throw new NotSupportedException();
}
@@ -229,13 +229,28 @@
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
</DataTemplate>
<!-- 이미지 -->
<!-- 이미지 — 해석기가 파일을 찾았으면 그 이미지를, 아니면 자리표시자를 그린다.
종이(미리보기·인쇄)에서는 자리표시자 테두리가 보이면 안 되므로 이미지가 있으면
테두리 없이 이미지만 남긴다. -->
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
<Grid>
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
<Border.Style>
<Style TargetType="Border">
<Setter Property="Visibility" Value="Visible"/>
<Style.Triggers>
<DataTrigger Binding="{Binding HasImage}" Value="True">
<Setter Property="Visibility" Value="Collapsed"/>
</DataTrigger>
</Style.Triggers>
</Style>
</Border.Style>
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
HorizontalAlignment="Center" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis"/>
</Border>
<Image Source="{Binding Image}" Stretch="{Binding ImageStretch}" IsHitTestVisible="False"/>
</Grid>
</DataTemplate>
<!-- 계산박스 -->
@@ -0,0 +1,90 @@
using System.Windows;
using System.Windows.Input;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 대화상자 크롬을 켠다 — <c>ThemedDialogWindow</c> 스타일이 <b>스타일 세터로</b> 걸어 준다.
///
/// <b>왜 첨부 속성인가.</b> 창 12종에 같은 타이틀바를 주려면 <see cref="ControlTemplate"/> 이 필요한데,
/// 템플릿 안의 닫기 버튼은 <c>Click</c> 핸들러를 가질 수 없다(템플릿은 코드비하인드가 없다).
/// 그래서 표준 <see cref="SystemCommands.CloseWindowCommand"/> 를 쓰고, 그 명령을 받을
/// <see cref="CommandBinding"/> 을 여기서 창에 달아 준다. 창마다 코드를 넣지 않아도 된다.
///
/// <b>최대화 보정도 함께 건다.</b> <c>WindowStyle=None</c> 인 창은 최대화하는 순간
/// 작업표시줄을 덮는다(메인 창에서 실제로 겪은 결함이다). 대화상자 대부분이 크기 조절 가능이라
/// 타이틀바를 더블클릭하면 그 상태에 들어갈 수 있다 — 크롬을 켜는 이 자리에서 같이 막는다.
/// </summary>
public static class DialogChrome
{
#region Attached Properties
/// <summary>크롬 사용 여부 — 스타일이 True 로 세팅한다</summary>
public static readonly DependencyProperty EnabledProperty =
DependencyProperty.RegisterAttached(
"Enabled", typeof(bool), typeof(DialogChrome),
new PropertyMetadata(false, OnEnabledChanged));
public static void SetEnabled(DependencyObject element, bool value)
=> element.SetValue(EnabledProperty, value);
public static bool GetEnabled(DependencyObject element)
=> (bool)element.GetValue(EnabledProperty);
#endregion
#region Methods
private static void OnEnabledChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
{
if (sender is not Window window || e.NewValue is not true)
{
return;
}
// 같은 창에 두 번 걸리지 않게 한다 — 스타일이 다시 적용되는 경로가 있다
foreach (CommandBinding existing in window.CommandBindings)
{
if (existing.Command == SystemCommands.CloseWindowCommand)
{
return;
}
}
// 크롬은 <b>창마다 새로</b> 만든다. WindowChrome 은 Freezable 이고 창에 붙으면서
// 스스로 속성을 고치므로, 한 인스턴스를 여러 창이 나눠 쓰면 두 번째 창부터
// "읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다.
// 그래서 XAML 스타일 세터로는 줄 수 없다(세터 값은 한 번 평가돼 공유된다).
//
// CaptionHeight 36 = 타이틀바 높이와 같아야 그 띠를 잡고 창을 끌 수 있다.
// GlassFrameThickness 0,0,0,1 = DWM 그림자 유지.
// ResizeBorderThickness 6 은 ResizeMode 가 허용할 때만 실제로 동작한다.
System.Windows.Shell.WindowChrome.SetWindowChrome(window, new System.Windows.Shell.WindowChrome
{
CaptionHeight = 36,
ResizeBorderThickness = new Thickness(6),
GlassFrameThickness = new Thickness(0, 0, 0, 1),
CornerRadius = new CornerRadius(0),
UseAeroCaptionButtons = false,
});
window.CommandBindings.Add(new CommandBinding(
SystemCommands.CloseWindowCommand,
(_, args) =>
{
// 창의 X 를 누른 것과 같아야 한다 — DialogResult 를 건드리지 않으므로
// ShowDialog 는 false 를 돌려주고, 호출부의 '취소' 경로가 그대로 탄다
SystemCommands.CloseWindow(window);
args.Handled = true;
}));
// 크기 조절 가능한 창만 최대화될 수 있다 — 그때 작업표시줄을 덮지 않게 한다
if (window.ResizeMode is ResizeMode.CanResize or ResizeMode.CanResizeWithGrip)
{
if (window.IsInitialized)
{
Services.MaximizeToWorkArea.Attach(window);
}
else
{
window.SourceInitialized += (_, _) => Services.MaximizeToWorkArea.Attach(window);
}
}
}
#endregion
}
@@ -0,0 +1,76 @@
using System.Globalization;
using System.Windows.Markup;
using System.Windows.Media;
namespace SheetMe.Designer.Controls;
/// <summary>
/// 이 PC 에 설치된 글꼴 이름 목록 — 인스펙터 글꼴 칸의 선택지.
///
/// <b>한글 이름을 낸다.</b> 레거시가 저장하는 값이 그것이다 — 실사용 서식의 Font 속성은
/// <c>"굴림, 11.25pt, style=Bold"</c> 처럼 GDI 가 주는 <b>지역화된</b> 이름을 담고 있다.
/// WPF 의 <see cref="FontFamily.Source"/> 는 같은 글꼴을 "Gulim" 으로 주므로,
/// 그걸 그대로 목록에 쓰면 사용자가 고른 순간 서식의 글꼴 이름이 영문으로 바뀐다.
/// 레거시가 그 이름을 못 찾으면 기본 글꼴로 떨어지고, 그 사실은 EMR 에서만 드러난다.
///
/// <b>목록에 없는 이름도 지운다는 뜻이 아니다.</b> 병원 PC 와 이 PC 의 설치 글꼴은 다르다.
/// 서식이 쓰는 글꼴이 여기 없을 수 있고, 그건 서식이 틀렸다는 뜻이 아니라
/// <b>지금 이 화면이 병원과 다르게 보인다</b>는 뜻이다 — 그래서 값을 고치지 않고 알리기만 한다.
/// </summary>
public static class InstalledFontCatalog
{
#region Member Fields
private static IReadOnlyList<string>? cached;
private static HashSet<string>? installed;
#endregion
#region Methods
/// <summary>설치된 글꼴 이름(한글 우선) — 가나다·알파벳 순, 한 번만 훑는다</summary>
public static IReadOnlyList<string> Families()
{
if (cached is not null)
{
return cached;
}
var names = new SortedSet<string>(StringComparer.Create(CultureInfo.GetCultureInfo("ko-KR"), ignoreCase: true));
foreach (var family in Fonts.SystemFontFamilies)
{
names.Add(DisplayNameOf(family));
}
cached = names.ToList();
installed = new HashSet<string>(cached, StringComparer.OrdinalIgnoreCase);
return cached;
}
/// <summary>이 PC 에 있는 글꼴인가 — 없으면 화면과 병원 출력이 다르다는 신호다</summary>
public static bool IsInstalled(string family)
{
if (family.Length == 0)
{
return true; // 미지정(상속)은 경고 대상이 아니다
}
Families();
return installed!.Contains(family);
}
/// <summary>
/// 이 글꼴의 표시 이름 — 한국어 이름이 있으면 그것, 없으면 원래 이름.
///
/// <c>FamilyNames</c> 는 글꼴이 스스로 담고 있는 언어별 이름표다. 한국어 글꼴은
/// ko-KR 항목에 "굴림"·"맑은 고딕" 을 갖고 있고, 레거시(GDI)가 쓰는 이름이 바로 그것이다.
/// </summary>
private static string DisplayNameOf(FontFamily family)
{
var korean = XmlLanguage.GetLanguage("ko-KR");
if (family.FamilyNames.TryGetValue(korean, out var name) && name.Length > 0)
{
return name;
}
// Source 는 "Gulim" 처럼 영문이거나, 조합 글꼴이면 "굴림, Arial" 같은 목록일 수 있다 —
// 목록이면 첫 이름만 쓴다(레거시 Font.Name 도 하나의 이름이다)
var source = family.Source;
var comma = source.IndexOf(',');
return comma < 0 ? source : source[..comma].Trim();
}
#endregion
}
+15 -1
View File
@@ -168,6 +168,15 @@ public static class LucideIcons
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
}
/// <summary>
/// 아이콘 색 칠하기 — 키가 있으면 리소스 참조(테마 추종), 없으면 넘겨받은 브러시.
///
/// <b>둘 다 없으면 검정이 아니라 B.Muted 다.</b> 전에는 <c>Brushes.Black</c> 으로 떨어졌는데,
/// 그건 다크 테마에서 <b>아이콘을 보이지 않게 만드는</b> 기본값이다.
/// 실제로 제목줄의 환자 아이콘이 그 경로로 혼자만 까맣게 묻혀 있었고,
/// 컴파일러가 CS8604 로 그 자리를 계속 가리키고 있었는데도 오래 남아 있었다.
/// 기본값을 테마 토큰으로 두면 어느 호출부도 같은 함정에 빠질 수 없다.
/// </summary>
private static void Paint(Path path, DependencyProperty property, Brush? color, string? brushKey)
{
if (brushKey is not null)
@@ -175,7 +184,12 @@ public static class LucideIcons
path.SetResourceReference(property, brushKey);
return;
}
path.SetValue(property, color ?? Brushes.Black);
if (color is not null)
{
path.SetValue(property, color);
return;
}
path.SetResourceReference(property, "B.Muted");
}
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
@@ -1,4 +1,5 @@
using SheetMe.Core.Models;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
using SheetMe.Data.Config;
using SheetMe.Data.Stores;
using SheetMe.Designer.Services;
@@ -105,6 +106,53 @@ internal sealed class FormDesignDataBusiness
return DbStore().SaveDesign(document, AuditUid);
}
/// <summary>
/// 제자리 정정 — 레거시 '기록지 수정'. 지금 쓰이는 SdgKey 를 유지한 채 디자인만 바꾼다.
/// <see cref="SaveToDb"/> 와 같은 게이트를 쓴다 — 이쪽이 더 되돌리기 어려우므로 더 느슨할 이유가 없다.
/// </summary>
public decimal ModifyInDb(FormDocument document)
{
if (!CanSaveToDb)
{
throw new InvalidOperationException(
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
}
return DbStore().ModifyDesign(document, AuditUid);
}
/// <summary>수정을 막아야 하는 새 컨트롤 이름들 — 비어 있으면 진행해도 된다</summary>
public List<string> NewControlNames(FormDocument document) => DbStore().NewControlNames(document);
/// <summary>이 사용자가 수정을 쓸 수 있는가 — 레거시는 전산실에만 열어 두었다</summary>
public static bool CanModify() => ModifyPolicy.CanModify(
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
HospitalStartDate);
public static string ModifyDenyReason() => ModifyPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary>
/// 이 사용자가 <b>실제 환자</b>로 미리보기를 할 수 있는가 — 인증만 확인한다.
///
/// 수정과 <b>다른</b> 문이다. 레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이고
/// TK_PREVIEW 에는 조건이 없다(frmSheetDesigner.vb:1030-1060).
/// 여기에 부서 문을 걸면 서식을 만든 사람이 자기 서식을 검증할 수 없다.
/// 안전장치는 막는 것이 아니라 <b>감사 로그</b>다.
/// </summary>
public static bool CanPreviewPatient() => PatientPreviewPolicy.CanPreviewPatient(
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
HospitalStartDate);
public static string PreviewPatientDenyReason() => PatientPreviewPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary>
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
/// 없는 값을 지어내 더 조이는 것보다, 레거시의 다수 경로와 같게 두고 이 사실을 적어 두는 쪽을 택한다.
/// </summary>
private const string HospitalStartDate = "";
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
@@ -0,0 +1,542 @@
using System.Runtime.InteropServices;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Media;
using System.Windows.Media.Effects;
using System.Windows.Media.Imaging;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// ClearType 이 실제로 꺼지는지 <b>화면을 찍어서</b> 잰다 (--cleartype).
///
/// <b>왜 화면을 찍는가.</b> <see cref="RenderTargetBitmap"/> 으로는 이 질문에 답할 수 없다 —
/// 그 자체가 알파를 가진 중간 서피스라서 ClearType 이 무조건 꺼진다.
/// 오프스크린으로 재면 Effect 가 있든 없든 양쪽 다 회색으로 나오고,
/// 검사는 <b>통과하면서 아무것도 증명하지 못한다</b>.
/// 그래서 진짜 화면(데스크톱 DC)을 BitBlt 으로 떠서 픽셀을 본다.
///
/// <b>무엇을 세는가.</b> ClearType 은 서브픽셀 렌더링이라 글리프 경계에 <b>채널이 어긋난 픽셀</b>
/// (R≠G≠B, 이른바 색 번짐)을 남긴다. 회색 안티에일리어싱은 R=G=B 만 남긴다.
/// 그래서 "채널 차이가 있는 픽셀 수"가 곧 ClearType 이 켜졌는지의 지표다.
///
/// <b>스스로 속지 않기 위한 장치.</b> 대조군(A: 아무 효과 없는 글자)에서 색 번짐이 0 이면
/// 시스템 ClearType 이 꺼졌거나 캡처 경로가 틀린 것이므로 <b>판정 불가</b>로 끝낸다.
/// 그 확인 없이 B 가 0 인 것을 근거로 삼으면 "둘 다 0"을 결론으로 착각한다.
/// </summary>
internal static class ClearTypeCheck
{
#region Member Fields
/// <summary>이 정도 채널 차이면 회색 AA 가 아니다 — 회색은 원리상 차이가 정확히 0 이다</summary>
private const int ChannelSpread = 6;
/// <summary>글자가 그려진 픽셀로 볼 밝기 상한(흰 배경 기준)</summary>
private const int InkLuma = 200;
private static readonly List<string> Lines = new();
private static int failures;
#endregion
#region Methods
public static int Run()
{
// F 표본이 앱의 실제 콤보 템플릿을 타야 하므로 테마를 실제로 적용한다
Services.ThemeManager.Apply(true);
var window = BuildProbe(out var samples, out var popups);
window.Show();
// ── 실제 창을 하나 잰다 ──
// 위 표본들은 <b>기법</b>이 듣는지를 잰다(합성 도형). 그것이 통과해도
// 실제 창에 기법을 <b>안 걸었으면</b> 글자는 계속 뭉개진다 — 실제로 그랬다.
// 쿼리 편집 창은 카드에 그림자 Effect 가 있어 그 안 글자가 전부 중간 서피스를 탄다.
// 카드 제목만 재던 것으로는 부족했다 — 제목은 켜졌는데 <b>SQL 본문</b>은 계속 꺼져 있었다.
// 사람이 실제로 들여다보는 표면을 다 재야 한다: 본문·변수 목록·결과 요약.
var real = OpenQueryEditorForClearType();
if (real is null)
{
Lines.Add("SKIP G~J(실제 창) — 창을 띄우지 못해 판정 불가");
}
else
{
foreach (var (label, name) in new[]
{
("G(실제 창) 카드 제목", "SQL 편집기"),
("H(실제 창) SQL 본문", "Highlight"),
("I(실제 창) 치환 변수 목록", "VariableList"),
})
{
var target = name == "SQL 편집기"
? FindTextBlock(real, name)
: real.FindName(name) as FrameworkElement;
if (target is { ActualWidth: > 0 })
{
samples.Add((label, target, true));
// ── 흐림의 <b>두 번째 축</b> ──
// 색 번짐이 있어도(=ClearType 켜짐) TextFormattingMode 가 Ideal 이면
// 글리프가 픽셀 격자에 안 맞아 11~14px 글자가 눌린 듯 흐려 보인다.
// 색 번짐을 세는 방식으로는 두 값이 구분되지 않는다 — 그래서 두 번 놓쳤다.
// 이건 픽셀이 아니라 <b>속성 값</b>으로 잠근다.
var mode = TextOptions.GetTextFormattingMode(target);
Check($"{label} → 화면 글자는 Display 로 그린다",
mode == TextFormattingMode.Display, $"실제 {mode}");
}
else
{
Lines.Add($"SKIP {label} — 대상을 찾지 못해 판정 불가");
}
}
}
// 창이 보이는 것과 합성기가 그려 낸 것은 다르다 — Loaded 직후 찍으면 빈 화면을 잰다.
// (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정)
var timer = new System.Windows.Threading.DispatcherTimer
{
Interval = TimeSpan.FromMilliseconds(600),
};
timer.Tick += (_, _) =>
{
timer.Stop();
try
{
Judge(samples);
}
finally
{
foreach (var popup in popups)
{
popup.IsOpen = false;
}
window.Close();
Application.Current.Shutdown();
}
};
timer.Start();
var dispatcherFrame = new System.Windows.Threading.DispatcherFrame();
Application.Current.Exit += (_, _) => dispatcherFrame.Continue = false;
System.Windows.Threading.Dispatcher.PushFrame(dispatcherFrame);
foreach (var line in Lines)
{
Console.Error.WriteLine(line);
}
Console.Error.WriteLine($"결과: 실패 {failures}건");
return failures == 0 ? 0 : 1;
}
/// <summary>
/// 실제 쿼리 편집 창을 띄워 카드 제목 글자를 잴 대상으로 내준다.
///
/// 합성 표본이 아니라 <b>배포되는 창</b>을 재야 한다 — 기법이 듣는 것과
/// 그 기법을 이 창에 걸어 둔 것은 다른 일이고, 후자가 빠져 있었다.
/// 창은 화면 안(0,0)에 띄운다. BitBlt 는 화면에 실제로 그려진 픽셀만 읽으므로
/// 화면 밖(-10000)에 두면 아무것도 못 잰다.
/// </summary>
private static Window? OpenQueryEditorForClearType()
{
try
{
var editor = new Views.QueryEditorWindow("MDataTable1",
"select PatChtNum, PatNam from P_PatInf where ROWNUM <= 1")
{
Left = 0,
Top = 0,
WindowStartupLocation = WindowStartupLocation.Manual,
};
editor.Show();
editor.UpdateLayout();
return editor;
}
catch (Exception ex)
{
Lines.Add($"SKIP G~I(실제 창) — {ex.GetType().Name}: {ex.Message}");
return null;
}
}
/// <summary>이 글자를 담은 TextBlock 을 찾는다 — 없으면 null</summary>
private static FrameworkElement? FindTextBlock(DependencyObject root, string text)
{
if (root is System.Windows.Controls.TextBlock block
&& block.Text.Contains(text, StringComparison.Ordinal))
{
return block;
}
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
if (FindTextBlock(System.Windows.Media.VisualTreeHelper.GetChild(root, i), text)
is { } found)
{
return found;
}
}
return null;
}
/// <summary>대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다</summary>
private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples)
{
var measured = samples
.Select(s => (s.Name, s.ExpectClearType, Result: Measure(s.Target)))
.ToList();
foreach (var (name, _, result) in measured)
{
// 잰 사각형을 함께 찍는다 — 두 표본이 같은 자리를 재고 있으면 숫자가 우연히 같아지는데,
// 좌표를 안 찍으면 그것을 "결과가 같다"로 읽는다(실제로 한 번 그렇게 읽었다).
Lines.Add($" {name,-40} 색번짐 {result.Fringed,6} / 글자픽셀 {result.Ink,6} @ {result.Rect}");
}
var control = measured[0];
if (control.Result.Fringed == 0)
{
Lines.Add("판정 불가 — 대조군에서 색 번짐이 0 이다.");
Lines.Add(" 시스템 ClearType 이 꺼져 있거나 캡처가 화면을 못 읽고 있다.");
Lines.Add(" 이 상태에서 나머지 0 은 근거가 되지 않는다.");
failures++;
return;
}
Lines.Add($"대조군 확인 — 색 번짐 {control.Result.Fringed}건. 이제 나머지 0 은 뜻이 있다.");
foreach (var (name, expect, result) in measured)
{
var on = result.Fringed > 0;
Check($"{name} → ClearType {(expect ? "" : "")}", on == expect,
$"실제 색번짐 {result.Fringed}건");
}
}
private static int Spread(int r, int g, int b)
=> Math.Max(r, Math.Max(g, b)) - Math.Min(r, Math.Min(g, b));
private static void Check(string label, bool ok, string detail = "")
{
Lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? $" {detail}" : string.Empty)}");
if (!ok)
{
failures++;
}
}
#endregion
#region Probe
/// <summary>
/// 같은 글자를 다섯 조건으로 놓는다. 배경은 전부 <b>불투명 흰색</b>이어야 한다 —
/// ClearType 은 뒤에 무엇이 있는지 알아야 서브픽셀을 계산할 수 있어서,
/// 투명 배경에서는 효과와 무관하게 회색으로 떨어진다.
/// </summary>
private static Window BuildProbe(
out List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples,
out List<Popup> popups)
{
var shadow = new DropShadowEffect
{
BlurRadius = 26, ShadowDepth = 5, Direction = 270,
Opacity = 0.14, Color = Colors.Black, RenderingBias = RenderingBias.Quality,
};
var plain = Text();
var effected = Text();
var hintOnEffected = Text();
var hintInside = Text();
var inPopup = Text();
var inPopupHinted = Text();
var effectedHost = Card(effected);
effectedHost.Effect = shadow;
// 변형 1 — 힌트를 Effect 가 걸린 요소 자신에게
var hostA = Card(hintOnEffected);
hostA.Effect = shadow.Clone();
RenderOptions.SetClearTypeHint(hostA, ClearTypeHint.Enabled);
// 변형 2 — 힌트를 중간 서피스 <b>안쪽</b>의 불투명 배경 요소에.
// 문서가 "불투명 배경이 있는 지점"이라고만 말해서 어느 쪽인지 재 봐야 안다.
var inner = new Border { Background = Brushes.White, Child = hintInside };
RenderOptions.SetClearTypeHint(inner, ClearTypeHint.Enabled);
var hostB = Card(inner);
hostB.Effect = shadow.Clone();
// F — 합성 표본이 아니라 <b>앱의 실제 콤보 템플릿</b>. ComboBox 스타일이 암시라 그대로 태워진다.
// 이 표본이 있어야 "플랫폼이 그렇다"에서 "이 앱이 그렇다"로 넘어갈 수 있다.
// 드롭다운은 AllowsTransparency 팝업 + FloatShadow Effect 가 겹쳐 걸린 자리다(DesignerTheme.xaml:548,553).
var themedItem = Text();
var combo = new ComboBox { Width = 300, HorizontalAlignment = HorizontalAlignment.Left };
combo.Items.Add(themedItem);
// 선택하지 않는다 — 선택 강조가 배경을 유채색으로 만들면 위 판정 장치가 이 표본을 거부한다
combo.SelectedIndex = -1;
var anchor = new Border { Width = 300, Height = 20, Background = Brushes.White };
var anchorHinted = new Border { Width = 300, Height = 20, Background = Brushes.White };
var anchorStacked = new Border { Width = 300, Height = 20, Background = Brushes.White };
var anchorSplit = new Border { Width = 300, Height = 20, Background = Brushes.White };
var anchorScrolled = new Border { Width = 300, Height = 20, Background = Brushes.White };
var anchorInnermost = new Border { Width = 300, Height = 20, Background = Brushes.White };
var popupPlain = LayeredPopup(inPopup, anchor);
var popupHinted = LayeredPopup(inPopupHinted, anchorHinted);
RenderOptions.SetClearTypeHint((FrameworkElement)popupHinted.Child, ClearTypeHint.Enabled);
// G — 앱의 현재 구조: 레이어드 팝업 <b>안에</b> Effect 가 걸린 Border, 그 안에 힌트.
// 중간 서피스가 두 겹이면 힌트가 듣는지 재 본다.
var stackedText = Text();
var stackedInner = new Border { Background = Brushes.White, Child = stackedText };
RenderOptions.SetClearTypeHint(stackedInner, ClearTypeHint.Enabled);
var stackedOuter = new Border { Background = Brushes.White, Child = stackedInner, Effect = shadow.Clone() };
var popupStacked = LayeredPopup(stackedOuter, anchorStacked, wrap: false);
// H — 후보 수리: 그림자를 <b>글자가 없는 형제</b>로 떼어낸다.
// 그러면 Effect 의 중간 서피스에 글자가 안 들어가고, 남는 것은 레이어드 창 한 겹뿐이라
// E 와 같은 조건이 된다.
var splitText = Text();
var shadowOnly = new Border { Background = Brushes.White, Effect = shadow.Clone() };
var splitContent = new Border { Background = Brushes.White, Child = splitText };
RenderOptions.SetClearTypeHint(splitContent, ClearTypeHint.Enabled);
var splitHost = new Grid();
splitHost.Children.Add(shadowOnly);
splitHost.Children.Add(splitContent);
var popupSplit = LayeredPopup(splitHost, anchorSplit, wrap: false);
// I — G 와 똑같지만 힌트와 글자 사이에 ScrollViewer 를 끼운다.
// 앱 콤보 드롭다운이 바로 이 모양인데 G 는 되고 F 는 안 되니, 남은 차이가 이것뿐이다.
var scrolledText = Text();
var scrolledInner = new Grid { Background = Brushes.White };
RenderOptions.SetClearTypeHint(scrolledInner, ClearTypeHint.Enabled);
scrolledInner.Children.Add(new ScrollViewer { Content = scrolledText });
var scrolledOuter = new Border
{
Background = Brushes.White, Child = scrolledInner, Effect = shadow.Clone(),
};
var popupScrolled = LayeredPopup(scrolledOuter, anchorScrolled, wrap: false);
// J — 힌트를 ScrollViewer <b>안쪽</b>(가장 안쪽 불투명 배경)에 건다.
var innermostText = Text();
var innermost = new Border { Background = Brushes.White, Child = innermostText };
RenderOptions.SetClearTypeHint(innermost, ClearTypeHint.Enabled);
var innerOuter = new Border
{
Background = Brushes.White,
Child = new ScrollViewer { Content = innermost },
Effect = shadow.Clone(),
};
var popupInnermost = LayeredPopup(innerOuter, anchorInnermost, wrap: false);
var stack = new StackPanel { Margin = new Thickness(20) };
stack.Children.Add(Card(plain));
stack.Children.Add(effectedHost);
stack.Children.Add(hostA);
stack.Children.Add(hostB);
stack.Children.Add(anchor);
// 팝업이 아래로 펼쳐질 자리 — 앵커끼리 겹치면 두 팝업이 같은 픽셀을 재게 된다
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(anchorHinted);
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(combo);
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(anchorStacked);
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(anchorSplit);
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(anchorScrolled);
stack.Children.Add(new Border { Height = 46 });
stack.Children.Add(anchorInnermost);
stack.Children.Add(new Border { Height = 46 });
var window = new Window
{
Title = "ClearType 검사",
Width = 480, Height = 1040,
Left = 60, Top = 60,
WindowStartupLocation = WindowStartupLocation.Manual,
Background = Brushes.White,
Content = stack,
Topmost = true,
};
window.ContentRendered += (_, _) =>
{
popupPlain.IsOpen = true;
popupHinted.IsOpen = true;
popupStacked.IsOpen = true;
popupSplit.IsOpen = true;
popupScrolled.IsOpen = true;
popupInnermost.IsOpen = true;
combo.IsDropDownOpen = true;
};
// 기대값은 <b>예측이 아니라 측정 결과</b>다. C1 과 E 는 내가 예상한 것과 반대로 나왔고
// 그 사실이 곧 수리 방법을 정한다 —
// 힌트는 Effect 가 걸린 요소 자신에게 걸면 듣지 않고(C1), 그 <b>안쪽</b> 불투명 배경에 걸면 듣는다(C2).
// 레이어드 팝업도 자식 불투명 배경에 걸면 되살아난다(E) — AllowsTransparency 를 끌 필요가 없다.
samples = new()
{
("A 아무것도 없음 (대조군)", plain, true),
("B Effect (그림자)", effected, false),
("C1 Effect + 힌트를 그 요소에", hintOnEffected, false),
("C2 Effect + 힌트를 안쪽 배경에", hintInside, true),
("D Popup AllowsTransparency", inPopup, false),
("E Popup + 힌트를 자식 배경에", inPopupHinted, true),
("F 앱 콤보 드롭다운 (실제 템플릿)", themedItem, true),
("G 팝업+Effect 두 겹 + 힌트", stackedText, true),
("H 팝업, 그림자를 형제로 분리 + 힌트", splitText, true),
("I 힌트 바깥에 ScrollViewer", scrolledText, false),
("J 힌트를 ScrollViewer 안쪽에", innermostText, true),
};
popups = new() { popupPlain, popupHinted, popupStacked, popupSplit, popupScrolled, popupInnermost };
return window;
}
/// <summary>글리프가 많고 획이 가는 글자 — 색 번짐이 가장 잘 드러난다</summary>
private static TextBlock Text() => new()
{
Text = "가나다라마 환자명 일자 ABCDEFG 0123456789",
FontFamily = new FontFamily("Malgun Gothic"),
FontSize = 13,
Foreground = Brushes.Black,
Margin = new Thickness(8, 5, 8, 5),
};
private static Border Card(UIElement child) => new()
{
Background = Brushes.White,
Margin = new Thickness(0, 0, 0, 18),
Child = child,
};
private static Popup LayeredPopup(UIElement child, UIElement target, bool wrap = true) => new()
{
PlacementTarget = target,
Placement = PlacementMode.Bottom,
AllowsTransparency = true,
StaysOpen = true,
Child = wrap ? new Border { Background = Brushes.White, Child = child } : child,
};
#endregion
#region Screen capture
/// <summary>
/// 이 요소가 실제로 그려진 화면 조각을 떠서, 채널이 어긋난 픽셀과 글자 픽셀을 센다.
/// </summary>
private static (int Fringed, int Ink, string Rect) Measure(FrameworkElement element)
{
if (!element.IsVisible || element.ActualWidth < 1 || element.ActualHeight < 1)
{
return (0, 0, "보이지 않음");
}
// PointToScreen 은 물리 화면 픽셀을 준다 — 배율이 걸려 있어도 그대로 쓸 수 있다
var topLeft = element.PointToScreen(new Point(0, 0));
var bottomRight = element.PointToScreen(new Point(element.ActualWidth, element.ActualHeight));
var x = (int)Math.Floor(Math.Min(topLeft.X, bottomRight.X));
var y = (int)Math.Floor(Math.Min(topLeft.Y, bottomRight.Y));
var width = (int)Math.Ceiling(Math.Abs(bottomRight.X - topLeft.X));
var height = (int)Math.Ceiling(Math.Abs(bottomRight.Y - topLeft.Y));
if (width < 1 || height < 1)
{
return (0, 0, "크기 0");
}
var pixels = Grab(x, y, width, height);
if (pixels is null)
{
return (0, 0, "캡처 실패");
}
// 배경을 먼저 정한다 — 가장 많이 나온 색이 배경이다.
//
// <b>왜 필요한가.</b> "채널 차이 = 서브픽셀 AA"는 배경이 무채색일 때만 성립한다.
// 유채색 배경(예: 선택된 콤보 항목의 B.Sel #C2E2FF, 채널 차이 61)에서는
// 글자와 무관하게 거의 모든 픽셀이 색 번짐으로 잡혀서 <b>ClearType 이 꺼져 있어도 통과한다</b>.
// 실제로 그렇게 통과시켰다 — 색번짐 4359 인데 글자픽셀은 1075 였다.
var histogram = new Dictionary<int, int>();
for (var i = 0; i + 3 < pixels.Length; i += 4)
{
var key = (pixels[i + 2] << 16) | (pixels[i + 1] << 8) | pixels[i];
histogram[key] = histogram.TryGetValue(key, out var seen) ? seen + 1 : 1;
}
var background = histogram.OrderByDescending(pair => pair.Value).First().Key;
int bgR = (background >> 16) & 0xFF, bgG = (background >> 8) & 0xFF, bgB = background & 0xFF;
if (Spread(bgR, bgG, bgB) > ChannelSpread)
{
return (0, 0, $"{x},{y} 배경이 유채색({bgR},{bgG},{bgB}) — 판정 불가");
}
var fringed = 0;
var ink = 0;
for (var i = 0; i + 3 < pixels.Length; i += 4)
{
int b = pixels[i], g = pixels[i + 1], r = pixels[i + 2];
// 배경 그 자체는 세지 않는다 — 글리프가 만든 픽셀만 본다
if ((r == bgR && g == bgG && b == bgB) is false && Spread(r, g, b) > ChannelSpread)
{
fringed++;
}
// Rec.601 근사 — 정수만으로 충분하다
if ((r * 299 + g * 587 + b * 114) / 1000 < InkLuma)
{
ink++;
}
}
return (fringed, ink, $"{x},{y} {width}x{height} 배경({bgR},{bgG},{bgB})");
}
/// <summary>데스크톱 DC 에서 사각형 하나를 떠 BGRA 바이트로 돌려준다</summary>
private static byte[]? Grab(int x, int y, int width, int height)
{
var screen = GetDC(IntPtr.Zero);
if (screen == IntPtr.Zero)
{
return null;
}
var memory = IntPtr.Zero;
var bitmap = IntPtr.Zero;
try
{
memory = CreateCompatibleDC(screen);
bitmap = CreateCompatibleBitmap(screen, width, height);
if (memory == IntPtr.Zero || bitmap == IntPtr.Zero)
{
return null;
}
var previous = SelectObject(memory, bitmap);
// SRCCOPY — 합성이 끝난 화면을 그대로 가져온다
if (!BitBlt(memory, 0, 0, width, height, screen, x, y, 0x00CC0020))
{
return null;
}
SelectObject(memory, previous);
var source = System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap(
bitmap, IntPtr.Zero, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
var stride = converted.PixelWidth * 4;
var buffer = new byte[stride * converted.PixelHeight];
converted.CopyPixels(buffer, stride, 0);
return buffer;
}
finally
{
if (bitmap != IntPtr.Zero)
{
DeleteObject(bitmap);
}
if (memory != IntPtr.Zero)
{
DeleteDC(memory);
}
ReleaseDC(IntPtr.Zero, screen);
}
}
[DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd);
[DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr dc);
[DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr dc);
[DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleBitmap(IntPtr dc, int width, int height);
[DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr dc, IntPtr obj);
[DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr obj);
[DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr dc);
[DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr dest, int x, int y, int width, int height,
IntPtr source, int sourceX, int sourceY, uint rop);
#endregion
}
File diff suppressed because it is too large Load Diff
+137 -6
View File
@@ -53,6 +53,13 @@ public static class DialogShots
: window.Background?.ToString() ?? "(null)";
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
// 그림은 증거만 만든다 — 잘렸는지는 값으로 단정해야 CI 가 잡는다
var violations = OverflowCheck.Inspect(window, out var inspected);
lines.Add($" 글자 {inspected}개 검사, 넘침 {violations.Count}건");
foreach (var violation in violations)
{
lines.Add($"FAIL 넘침 {name}-{suffix} {violation}");
}
}
catch (Exception ex)
{
@@ -63,6 +70,8 @@ public static class DialogShots
lines.Add(string.Empty);
lines.AddRange(StyleSelfCheck());
lines.Add(string.Empty);
lines.AddRange(OverflowCheck.SelfCheck());
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
string.Join(Environment.NewLine, lines));
@@ -111,7 +120,22 @@ public static class DialogShots
var designer = SampleDesigner();
return new List<(string, Func<Window>)>
{
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
// 메인 셸 — 이 게이트에 <b>아예 없었다</b>. 사용자가 하루 종일 보는 화면만 자동 검증 밖이었다.
// 두 크기로 찍는다: 기본 창(1440×920)과 현장 단말 최대화 크기(1920×1032).
// 현장이 1920×1080 이상뿐이라는 답을 받았으므로 그 사각형이 실제 기준이다.
("00-main-default", () => NewMainShell(1440, 920)),
("00b-main-1920", () => NewMainShell(1920, 1032)),
// 도구 상자 탭은 타일 라벨이 좁은 2열에 들어가므로 잘림이 가장 잘 나는 자리다
("00c-main-toolbox", () => NewMainShell(1440, 920, leftTab: 2)),
// 'DB에서 열기' 대화상자를 지웠다. 그 자리를 좌측 서식 목록 샷이 대신한다 —
// 안 그러면 서식명 잘림을 잡을 자리가 게이트에서 통째로 사라진다.
("01-sheet-list", () => NewMainShell(1440, 920, leftTab: 0, sheets: SampleSheets())),
// 문서 없는 시작 화면 — '새 서식'을 없앤 뒤로는 이것이 <b>기본 상태</b>다.
// 여기가 대조군이다: 00-main-default 는 문서가 있어 인스펙터가 보여야 하고,
// 이 샷은 없어서 접혀야 한다. 접히지 않으면 DataContext 가 null 인 채로
// 안쪽 Visibility 바인딩이 전부 실패해 정렬 바·간격값이 남는다(WPF 는 실패를 Visible 로 떨군다).
("00d-main-empty", () => NewMainShell(1440, 920, leftTab: 0,
sheets: SampleSheets(), withDocument: false)),
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
@@ -131,6 +155,12 @@ public static class DialogShots
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)),
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
// 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
// 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")),
@@ -194,6 +224,72 @@ public static class DialogShots
/// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 <c>CurrentDesigner</c> 하나만
/// 들고 있는 최소 DataContext 를 세워 준다.
/// </summary>
/// <summary>
/// 메인 셸을 주어진 크기로 만든다.
///
/// Loaded 커맨드(서식 목록 DB 조회)는 <see cref="StartupArguments.DiagnosticMode"/> 때문에 돌지 않으므로
/// 접속 없이도 같은 결과가 나온다. 문서 한 장을 넣어 캔버스·레이어·인스펙터가 빈 상태가 아니게 한다 —
/// 빈 껍데기를 찍으면 잘림을 볼 수 없다.
/// </summary>
private static Window NewMainShell(double width, double height, int leftTab = 1,
List<SheetSummary>? sheets = null, bool withDocument = true)
{
var window = new Views.MainView
{
Width = width,
Height = height,
Left = -10000,
Top = -10000,
WindowStartupLocation = WindowStartupLocation.Manual,
};
if (window.DataContext is not MainViewModel shell)
{
return window;
}
if (withDocument)
{
shell.AttachBlankForDiagnostics();
}
shell.SelectedLeftTabIndex = leftTab;
// 진단 모드는 Loaded 커맨드를 안 돌리므로 목록이 비어 있다 —
// 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다.
if (sheets is not null)
{
shell.SheetList.Clear();
foreach (var sheet in sheets)
{
shell.SheetList.Add(sheet);
}
}
// 컨트롤을 놓고 하나를 고른다 — 이걸 안 하면 인스펙터가 비어 있고 레이어에 행이 없어서
// 검사할 글자가 31개밖에 안 나온다(빈 껍데기를 찍으면 잘림을 볼 수 없다).
// 이름이 긴 컨트롤을 섞는다 — 좁은 칸에서 잘리는 것은 짧은 이름이 아니라 긴 이름이다.
if (shell.CurrentDesigner is { } designer)
{
foreach (var type in new[] { "Label", "TextBox", "CheckBox", "MDataTable", "MPictureBox" })
{
designer.AddPaletteItemAtCenter(type);
}
foreach (var control in designer.Pages[0].Controls)
{
if (control.Type == "Label")
{
control.Model.Id = "아주긴이름을가진라벨컨트롤_LAB_00123";
control.Model.Props.SetText("Text",
"잘림을 보려면 충분히 긴 문구가 필요하다 — 간호·간병통합서비스 병동");
control.NotifyAllChanged();
break;
}
}
if (designer.Pages[0].Controls.FirstOrDefault() is { } first)
{
designer.Selection.SetSingle(first);
}
}
return window;
}
private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
{
designer.Selection.Clear();
@@ -294,18 +390,26 @@ public static class DialogShots
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
Pump();
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고,
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다).
// <b>창 전체</b>를 찍는다 — Content 만 찍으면 타이틀바가 통째로 빠진다.
//
// 전에는 Content 를 찍었다. 그래서 창 크롬을 통일하기 전까지 이 진단은
// "열한 창이 윈도우 기본 타이틀바를 달고 뜬다"는 사실을 <b>한 번도 보여 준 적이 없었다</b> —
// 고쳐야 할 바로 그 부분에 눈이 없었던 셈이다. 이제 커스텀 타이틀바는 창의 Template
// 안에 있으므로 Content 만 찍으면 여전히 안 보인다.
//
// 배경을 먼저 칠하는 것은 그대로 둔다 — 투명 픽셀이 PNG 에서 검정으로 저장되면
// 라이트 테마 결함이 "다크처럼 보이는" 착시에 가려진다.
var content = (FrameworkElement)window.Content;
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width);
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height);
var root = (FrameworkElement?)window.Template?.FindName("PART_Root", window) ?? content;
var width = (int)Math.Ceiling(window.ActualWidth > 0 ? window.ActualWidth : window.Width);
var height = (int)Math.Ceiling(window.ActualHeight > 0 ? window.ActualHeight : window.Height);
var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
var drawing = new DrawingVisual();
using (var context = drawing.RenderOpen())
{
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height));
context.DrawRectangle(new VisualBrush(content) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
context.DrawRectangle(new VisualBrush(root) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
null, new Rect(0, 0, width, height));
}
bitmap.Render(drawing);
@@ -340,6 +444,33 @@ public static class DialogShots
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
};
/// <summary>
/// 데이터소스 표본 — 환자 없이 만든다. 그러면 치환이 전부 빈 값이 되어 SQL 이 깨지고,
/// 진단이 <b>실패 화면</b>을 찍는다. 그게 사람이 실제로 오래 보게 되는 화면이다.
/// DB 접속 여부와 무관하게 같은 그림이 나온다(치환 단계에서 이미 실패한다).
/// </summary>
private static Services.MDataTableRunner SampleRunner()
{
var document = new SheetMe.Core.Models.FormDocument();
var page = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
document.Pages.Add(page);
foreach (var (id, query) in new[]
{
("MDataTable1", "SELECT * FROM P_ComInf WHERE ComNum = <<bzPatientInfo.ComNum>>"),
("MDataTable2", "SELECT 1 FROM DUAL"),
})
{
var source = new SheetMe.Core.Models.ControlElement
{
Type = SheetMe.Core.Serialization.LegacyTypeCatalog.DataTableType, Id = id,
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
};
source.Props.SetText("Query", query);
page.Controls.Add(source);
}
return new Services.MDataTableRunner(document, null);
}
private static List<DesignVersionInfo> SampleVersions() => new()
{
new DesignVersionInfo(52443, "202608121030", "011825", false),
File diff suppressed because it is too large Load Diff
@@ -36,9 +36,20 @@ public static class MaximizeCheck
private const uint AbmGetAutoHideBarEx = 0x0000000B;
private const int AbsAutoHide = 0x00000001;
private const int ProbeMinWidth = 940;
private const int ProbeMinHeight = 640;
private const double ProbeBorder = 6;
/// <summary>
/// 검사에 쓰는 최소 크기·테두리는 <b>실제 MainView 에서 읽는다</b>.
///
/// 전에는 940 / 640 / 6 을 상수로 베껴 두었다. 그러면 MainView 의 최소 크기를 바꿨을 때
/// 이 검사가 <b>옛 값을 계속 검사하며 계속 통과한다</b> — 게이트가 있는데 아무것도 지키지 않는 상태다.
/// 창을 하나 만들어(띄우지 않는다) XAML 이 정한 값을 그대로 가져온다.
/// </summary>
private static (double MinWidth, double MinHeight, double Border) Real()
{
var main = new Views.MainView();
var chrome = WindowChrome.GetWindowChrome(main);
var border = chrome?.ResizeBorderThickness.Left ?? 6;
return (main.MinWidth, main.MinHeight, border);
}
[StructLayout(LayoutKind.Sequential)]
private struct RECT
@@ -152,7 +163,9 @@ public static class MaximizeCheck
var failures = 0;
// 프로브는 WindowStyle=None 이어야 한다 — 기본값이면 Attach 의 가드에 걸려 아무 일도 안 일어난다
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
var real = Real();
report.AppendLine($"MainView 실측 — 최소 {real.MinWidth}x{real.MinHeight}, 리사이즈 테두리 {real.Border}");
var probe = NewProbe(real.MinWidth, real.MinHeight);
var handle = new WindowInteropHelper(probe).EnsureHandle();
Services.MaximizeToWorkArea.Attach(probe);
@@ -226,8 +239,9 @@ public static class MaximizeCheck
private static int Live(StringBuilder report, MonitorFacts monitor)
{
var failures = 0;
var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(ProbeBorder) };
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
var real = Real();
var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(real.Border) };
var probe = NewProbe(real.MinWidth, real.MinHeight);
WindowChrome.SetWindowChrome(probe, chrome);
probe.WindowStartupLocation = WindowStartupLocation.Manual;
probe.Left = monitor.Bounds.Left + 40;
@@ -263,7 +277,7 @@ public static class MaximizeCheck
var handle = new WindowInteropHelper(probe).Handle;
probe.MinHeight = workHeight + 200;
var cramped = Ask(handle);
probe.MinHeight = ProbeMinHeight;
probe.MinHeight = real.MinHeight;
probe.Close();
report.AppendLine($"좁은 화면 흉내 — 최소 높이 {workHeight + 200} 요구, 작업영역 높이 {workHeight}");
@@ -0,0 +1,184 @@
using System.Windows;
using System.Windows.Threading;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 모달 가림막 자동 검증 — <c>--modal-check</c>.
///
/// <b>무엇을 재는가.</b> 대화상자를 실제로 <c>ShowDialog()</c> 로 띄우고,
/// 그 순간 화면에 가림막 창이 실제로 존재하는지 센다.
///
/// <b>왜 필요했나.</b> 처음 만든 판정이 틀렸다. <c>ShowDialog()</c> 는 창을 먼저 보이고
/// (그때 Loaded 가 뜬다) <b>그다음에</b> 모달 루프에 들어가므로,
/// Loaded 시점의 <c>ComponentDispatcher.IsThreadModal</c> 은 아직 false 다.
/// 그래서 가림막이 한 번도 안 깔렸는데 코드는 멀쩡해 보였다 — 눈으로 열어 보기 전엔 모른다.
/// </summary>
public static class ModalCheck
{
#region Methods
public static int Run()
{
var lines = new List<string>();
var failed = 0;
void Check(string label, bool ok, string detail = "")
{
lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? " " + detail : string.Empty)}");
if (!ok)
{
failed++;
}
}
try
{
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
Services.ThemeManager.Apply(light: true);
// 실제 앱은 OnStartup 에서 이 배선을 건다. 진단 분기는 그보다 먼저 반환하므로
// 검사에서 같은 배선을 직접 걸어야 프로덕션과 같은 것을 재게 된다.
EventManager.RegisterClassHandler(typeof(Window), FrameworkElement.LoadedEvent,
new RoutedEventHandler((sender, _) =>
{
if (sender is Window loaded)
{
Services.ModalScrim.Attach(loaded);
}
}));
// 뒤에 깔릴 창 — 실제 앱에서는 메인 셸이 이 자리다
var owner = new Window
{
Title = "가림막 검사 — 뒤 창",
Width = 600,
Height = 400,
Left = 80,
Top = 80,
ShowInTaskbar = false,
};
owner.Show();
Application.Current.MainWindow = owner;
var dialog = new Window
{
Title = "가림막 검사 — 대화상자",
Width = 300,
Height = 200,
Owner = owner,
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
// 자격을 밝힌 창만 어두워진다 — 아래 ⑤ 가 밝히지 않은 창을 따로 확인한다
Services.ModalScrim.SetDimBehind(dialog, true);
var scrimsWhileOpen = -1;
var dialogAbove = false;
var modalAtLoaded = false;
dialog.Loaded += (_, _) =>
modalAtLoaded = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
// 대화상자가 떠 있는 동안 창 목록을 세고 닫는다.
// BeginInvoke 로는 안 된다 — 이 호출이 Loaded 보다 먼저 큐에 들어가므로
// 가림막을 거는 콜백보다 앞서 실행돼 항상 0 을 센다(실제로 그렇게 헛다리를 짚었다).
var modalAtProbe = false;
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
timer.Tick += (_, _) =>
{
timer.Stop();
modalAtProbe = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
scrimsWhileOpen = CountScrims();
dialogAbove = DialogIsAboveScrim(dialog);
dialog.Close();
};
timer.Start();
dialog.ShowDialog();
lines.Add($"Loaded 시점 IsThreadModal = {modalAtLoaded}");
lines.Add($"모달 루프 안 IsThreadModal = {modalAtProbe}");
lines.Add($"대화상자가 떠 있는 동안의 가림막 수 = {scrimsWhileOpen}");
Check("모달일 때 가림막이 깔린다", scrimsWhileOpen == 1,
$"실제 {scrimsWhileOpen}개");
// 가림막이 대화상자 위에 오면 대화상자까지 같이 흐려진다 — 실제로 그렇게 만들었다
Check("가림막이 대화상자 아래에 있다", dialogAbove);
Check("닫으면 가림막이 걷힌다", CountScrims() == 0, $"실제 {CountScrims()}개");
// ⑤ 자격을 밝히지 않은 창은 어두워지지 않는다.
//
// 이것이 이 검사에서 가장 중요한 항목이다. 전에는 모든 ShowDialog 가 무조건 어두워져서
// 태그 하나 고르는 데도 앱이 암전했다 — 하루 수백 번이면 신호가 아니라 소음이다.
// 위 ①~④ 만 있으면 "깔린다"는 확인되지만 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다.
var plainScrims = -1;
var plain = new Window
{
Title = "가림막 검사 — 자격 없는 대화상자",
Width = 300, Height = 200, Owner = owner,
ShowInTaskbar = false,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
};
var plainTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
plainTimer.Tick += (_, _) =>
{
plainTimer.Stop();
plainScrims = CountScrims();
plain.Close();
};
plainTimer.Start();
plain.ShowDialog();
lines.Add($"자격 없는 대화상자가 떠 있는 동안의 가림막 수 = {plainScrims}");
Check("자격을 밝히지 않으면 어두워지지 않는다", plainScrims == 0, $"실제 {plainScrims}개");
owner.Close();
}
catch (Exception ex)
{
lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}");
failed++;
}
lines.Add($"결과: 실패 {failed}건");
Console.Error.WriteLine(string.Join(Environment.NewLine, lines));
return failed == 0 ? 0 : 1;
}
[System.Runtime.InteropServices.DllImport("user32.dll")]
private static extern IntPtr GetWindow(IntPtr hwnd, uint command);
/// <summary>
/// 대화상자 바로 다음(= 아래) 창이 가림막인가.
/// GW_HWNDNEXT = 2 는 z순서에서 이 창 다음에 오는 창을 준다.
/// </summary>
private static bool DialogIsAboveScrim(Window dialog)
{
var dialogHandle = new System.Windows.Interop.WindowInteropHelper(dialog).Handle;
var next = GetWindow(dialogHandle, 2);
foreach (Window window in Application.Current.Windows)
{
if (window.AllowsTransparency && !window.ShowInTaskbar
&& new System.Windows.Interop.WindowInteropHelper(window).Handle == next)
{
return true;
}
}
return false;
}
/// <summary>가림막은 투명 + 작업표시줄 미표시 + 소유자 있는 창이다</summary>
private static int CountScrims()
{
var count = 0;
foreach (Window window in Application.Current.Windows)
{
if (window.AllowsTransparency && !window.ShowInTaskbar && window.Owner is not null
&& window.WindowStyle == WindowStyle.None)
{
count++;
}
}
return count;
}
#endregion
}
@@ -0,0 +1,327 @@
using System.Globalization;
using System.Windows;
using System.Windows.Controls;
using System.Windows.Media;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 레이아웃이 글자를 잘라먹었는지 <b>단정</b>한다.
///
/// <b>왜 필요한가.</b> 지금 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없다.
/// 테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
/// --dialog-shots 는 PNG 57장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
/// 즉 회귀를 잡는 유일한 수단이 <b>사람이 그림 57장을 보는 것</b>이었다.
/// 실제로 이번 세션에만 "232 로는 이름이 잘린다", "220 이면 마지막 토글이 잘린다",
/// "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다" 세 건이 눈으로만 잡혔다.
///
/// <b>왜 글자만 보는가.</b> WPF 에서 <c>DesiredSize &gt; RenderSize</c> 는 정상인 경우가 많다
/// (Auto 칸, 클리핑을 의도한 컨테이너, 측정 전 요소). 전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.
/// 잘려서 <b>실제로 손해가 나는 것</b>은 읽어야 할 글자뿐이라 거기만 본다.
/// </summary>
internal static class OverflowCheck
{
#region Member Fields
/// <summary>서브픽셀 반올림 오차 — 이보다 작은 초과는 잘린 것이 아니다</summary>
private const double Slack = 0.75;
#endregion
#region Types
internal readonly record struct Violation(string Kind, string Text, string Detail)
{
public override string ToString() => $"{Kind}: \"{Text}\" — {Detail}";
}
#endregion
#region Methods
/// <summary>
/// 이 창의 시각 트리를 훑어 잘린 글자를 찾는다. <b>Measure/Arrange 가 끝난 뒤</b> 불러야 한다.
///
/// <paramref name="inspected"/> 를 함께 돌려주는 이유: 위반 0건은
/// "깨끗하다"와 "훑기가 깨졌다"를 구분해 주지 않는다. 몇 개를 봤는지 같이 적어야
/// 0 이 뜻을 갖는다(--cleartype 의 대조군과 같은 장치다).
/// </summary>
public static List<Violation> Inspect(Visual root, out int inspected)
{
var found = new List<Violation>();
inspected = 0;
Walk(root, found, ref inspected);
return found;
}
/// <summary>
/// <b>대조군.</b> 일부러 잘린 글자 넷을 만들어 검사기가 실제로 잡는지 확인한다.
///
/// 이것이 없으면 위반 0건이 "화면이 깨끗하다"가 아니라 "검사기가 고장났다"일 수 있고,
/// 그 둘은 보고서에서 똑같이 생겼다. 이번 세션에만 그 함정에 두 번 빠졌다
/// (--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
/// --modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다).
/// </summary>
public static List<string> SelfCheck()
{
var lines = new List<string> { "[넘침 검사기 자가 점검 — 일부러 잘린 것을 잡는가]" };
// 대조군은 <b>실제로 손해가 나는 모양</b>이어야 한다. 처음에는 "Width 를 좁게 주고 말줄임표를 끈"
// 것을 잘림으로 놓았는데, 실측해 보니 그건 잘리는 게 아니라 자연폭 그대로 그려지는 것이었다
// (ActualWidth 가 213.3 으로 나왔다). 대조군이 틀렸던 것이라 모양을 바꿨다.
var zeroHost = new Grid { Width = 0, ClipToBounds = true };
var zeroText = Text("0 크기 칸에 갇힌 글자", TextTrimming.CharacterEllipsis, tip: false);
zeroHost.Children.Add(zeroText);
var spillHost = new Border { Width = 40, Height = 20, ClipToBounds = true };
var spillText = Text("자르는 칸 밖으로 삐져나간 긴 문구", TextTrimming.None, tip: false);
spillHost.Child = spillText;
var trimmedNoTip = Text("말줄임표는 있고 툴팁은 없는 긴 문구", TextTrimming.CharacterEllipsis, tip: false);
trimmedNoTip.Width = 40;
var trimmedWithTip = Text("툴팁이 있으니 전체를 읽을 수 있다", TextTrimming.CharacterEllipsis, tip: true);
trimmedWithTip.Width = 40;
// 0 폭 칸에 갇힌 글자는 ActualWidth 가 정확히 0 이 되지 않는다 — 말줄임표 글리프 몫으로 9.6 이 남는다.
// 그래서 이 대조군의 정답은 '크기0' 이 아니라 두 규칙이 함께 걸리는 것이다.
// 이것도 처음에 기대값을 잘못 적었고 실측이 정정했다.
// '크기0' 규칙 자체는 남긴다 — 0 크기 Grid 가 자식을 폭 0 으로 재던 실제 버그를 겨눈 것이고,
// 합성으로 그 모양을 정확히 만들지 못한다는 사실은 규칙이 틀렸다는 뜻이 아니다.
var cases = new (string Expect, TextBlock Element)[]
{
("컨테이너넘침,툴팁없이잘림", zeroText),
("컨테이너넘침", spillText),
("툴팁없이잘림", trimmedNoTip),
("통과", trimmedWithTip),
};
var host = new StackPanel();
host.Children.Add(zeroHost);
host.Children.Add(spillHost);
host.Children.Add(trimmedNoTip);
host.Children.Add(trimmedWithTip);
var window = new Window
{
Width = 400, Height = 300, Left = -6000, Top = -6000,
WindowStartupLocation = WindowStartupLocation.Manual,
ShowActivated = false, ShowInTaskbar = false, Content = host,
};
window.Show();
window.UpdateLayout();
foreach (var (expect, element) in cases)
{
var hits = Inspect(element, out var seen);
var kinds = hits.Count == 0 ? "통과" : string.Join(",", hits.Select(h => h.Kind));
var ok = kinds == expect && seen == 1;
var block = element;
lines.Add($"{(ok ? "PASS" : "FAIL")} 대조군 {expect,-14} → {kinds} (검사 {seen}개)"
+ $" [보임={block.IsVisible} 폭={block.ActualWidth:0.#} 높이={block.ActualHeight:0.#}"
+ $" 자연폭={(block.IsVisible ? NaturalWidth(block) : 0):0.#} 줄바꿈={block.TextWrapping}]");
}
window.Close();
return lines;
}
private static TextBlock Text(string text, TextTrimming trimming, bool tip)
{
var block = new TextBlock
{
Text = text,
FontSize = 13,
TextWrapping = TextWrapping.NoWrap,
TextTrimming = trimming,
HorizontalAlignment = HorizontalAlignment.Left,
};
if (tip)
{
block.ToolTip = text;
}
return block;
}
private static void Walk(DependencyObject node, List<Violation> found, ref int inspected)
{
if (node is TextBlock text)
{
inspected++;
Judge(text, found);
}
var count = VisualTreeHelper.GetChildrenCount(node);
for (var i = 0; i < count; i++)
{
Walk(VisualTreeHelper.GetChild(node, i), found, ref inspected);
}
// 팝업 내용은 시각 트리에 안 걸리므로 논리 자식으로 따라 들어간다 —
// 메뉴·플라이아웃·자동완성이 전부 이 경로에 있다
if (node is FrameworkElement { } element)
{
foreach (var child in LogicalTreeHelper.GetChildren(element))
{
if (child is System.Windows.Controls.Primitives.Popup { Child: { } popupChild })
{
Walk(popupChild, found, ref inspected);
}
}
}
}
private static void Judge(TextBlock text, List<Violation> found)
{
var content = text.Text;
if (!text.IsVisible || content.Length == 0 || IsWhiteSpace(content))
{
return;
}
var label = content.Length > 28 ? content[..28] + "…" : content;
var owner = OwnerOf(text);
// ① 보이는데 크기가 0 — 이번 세션에 실제로 낸 버그다(0 크기 Grid 가 자식을 폭 0 으로 잰다).
// 이 경우 사용자는 글자가 <b>아예 없는</b> 것으로 본다.
if (text.ActualWidth < 0.5 || text.ActualHeight < 0.5)
{
found.Add(new Violation("크기0", label,
$"{owner} ActualWidth={text.ActualWidth:0.#} ActualHeight={text.ActualHeight:0.#}"));
return;
}
// ② 세로로 잘림 — 줄바꿈이 일어났는데 높이를 안 준 경우다.
//
// <b>Margin 을 빼야 한다.</b> DesiredSize 는 Margin 을 포함하고 RenderSize 는 포함하지 않는다.
// 그래서 그냥 비교하면 세로 Margin 이 있는 TextBlock 이 예외 없이 걸린다 —
// 처음에 그렇게 만들어 30건이 떴고, 전부 오탐이었다
// (도구 상자 그룹 머리가 "필요 27 > 실제 14" 로 나왔는데 그 13 이 Margin 이었다).
var needHeight = text.DesiredSize.Height - text.Margin.Top - text.Margin.Bottom;
if (needHeight - text.RenderSize.Height > Slack)
{
found.Add(new Violation("세로잘림", label,
$"{owner} 필요 {needHeight:0.#} > 실제 {text.RenderSize.Height:0.#}"));
}
// ③ 자기를 자르는 조상 밖으로 삐져나갔는가.
//
// <b>왜 ActualWidth 로는 못 잡는가.</b> TextTrimming 이 None 이면 TextBlock 은 주어진 Width 를
// 지키지 않고 자연폭을 그대로 ActualWidth 로 보고한다(실측: Width=40 인데 ActualWidth=213.3).
// 즉 WPF 에서 말줄임표 없는 NoWrap 글자는 <b>잘리는 것이 아니라 옆을 침범한다</b>.
// 그래서 "글자 자연폭 > 자기 ActualWidth" 는 이 경우 항상 거짓이고, 대신
// <b>자르는 조상의 사각형</b>과 비교해야 한다.
Clipped(text, label, owner, found);
// ④ 말줄임표가 실제로 걸렸는가 — 이때는 ActualWidth 가 좁혀지므로 자연폭과 비교할 수 있다.
if (text.TextWrapping != TextWrapping.NoWrap || text.TextTrimming == TextTrimming.None)
{
return;
}
var natural = NaturalWidth(text);
var available = text.ActualWidth - text.Padding.Left - text.Padding.Right;
if (natural - available <= Slack)
{
return;
}
// 말줄임표는 있지만 툴팁이 없으면 전체 문구를 볼 방법이 없다.
if (text.ToolTip is null && ToolTipService.GetToolTip(text) is null && !AncestorHasToolTip(text))
{
found.Add(new Violation("툴팁없이잘림", label,
$"{owner} 필요 {natural:0.#} > 가용 {available:0.#}"));
}
}
/// <summary>
/// 글자의 사각형을 <b>자기를 자르는 가장 가까운 조상</b>의 좌표로 옮겨 비교한다.
/// 자르는 조상이 없으면 삐져나가도 안 잘리므로(옆과 겹칠 뿐) 위반으로 세지 않는다 —
/// 겹침은 다른 종류의 결함이고 이 검사기가 판정할 수 있는 것이 아니다.
/// </summary>
private static void Clipped(TextBlock text, string label, string owner, List<Violation> found)
{
var node = (DependencyObject)text;
for (var depth = 0; depth < 24; depth++)
{
node = VisualTreeHelper.GetParent(node);
if (node is not FrameworkElement { IsVisible: true } ancestor)
{
if (node is null)
{
return;
}
continue;
}
// ScrollViewer 안은 스크롤로 볼 수 있으므로 잘린 것이 아니다
if (ancestor is ScrollViewer or ScrollContentPresenter)
{
return;
}
if (!ancestor.ClipToBounds && ancestor.Clip is null)
{
continue;
}
var box = text.TransformToAncestor(ancestor)
.TransformBounds(new Rect(0, 0, text.ActualWidth, text.ActualHeight));
var overRight = box.Right - ancestor.ActualWidth;
var overBottom = box.Bottom - ancestor.ActualHeight;
if (overRight > Slack || overBottom > Slack || box.Left < -Slack || box.Top < -Slack)
{
found.Add(new Violation("컨테이너넘침", label,
$"{owner} → {ancestor.GetType().Name}"
+ $"({ancestor.ActualWidth:0.#}×{ancestor.ActualHeight:0.#}) 밖으로 "
+ $"우 {Math.Max(0, overRight):0.#} 하 {Math.Max(0, overBottom):0.#}"));
}
return;
}
}
/// <summary>이 글자가 한 줄로 다 그려지려면 몇 픽셀이 필요한가</summary>
private static double NaturalWidth(TextBlock text)
{
var typeface = new Typeface(text.FontFamily, text.FontStyle, text.FontWeight, text.FontStretch);
// 요소와 같은 서식 모드로 재야 한다 — Ideal 과 Display 는 글립 진행이 달라
// 같은 글자도 폭이 다르게 나온다. 이 값은 생성자 인자로만 줄 수 있다.
var formatted = new FormattedText(
text.Text,
CultureInfo.CurrentUICulture,
text.FlowDirection,
typeface,
text.FontSize,
Brushes.Black,
numberSubstitution: null,
TextOptions.GetTextFormattingMode(text),
VisualTreeHelper.GetDpi(text).PixelsPerDip);
return formatted.WidthIncludingTrailingWhitespace;
}
/// <summary>행 전체에 툴팁이 걸린 경우가 흔하다 — 그러면 글자에 없어도 읽을 수 있다</summary>
private static bool AncestorHasToolTip(DependencyObject node)
{
for (var i = 0; i < 6 && node is not null; i++)
{
node = VisualTreeHelper.GetParent(node);
if (node is FrameworkElement { } element
&& (element.ToolTip is not null || ToolTipService.GetToolTip(element) is not null))
{
return true;
}
}
return false;
}
/// <summary>어디서 잘렸는지 — 이름 있는 가장 가까운 조상을 짚어 준다</summary>
private static string OwnerOf(DependencyObject node)
{
for (var i = 0; i < 10 && node is not null; i++)
{
if (node is FrameworkElement { Name.Length: > 0 } named)
{
return named.Name;
}
node = VisualTreeHelper.GetParent(node);
}
return "(이름없음)";
}
private static bool IsWhiteSpace(string value)
{
foreach (var character in value)
{
if (!char.IsWhiteSpace(character))
{
return false;
}
}
return true;
}
#endregion
}
@@ -0,0 +1,199 @@
using System.Diagnostics;
using System.Text;
using System.Windows;
using SheetMe.Core.Models;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
namespace SheetMe.Designer.Diagnostics;
/// <summary>
/// 운영 규모에서 상호작용이 얼마나 걸리는지 잰다 — <c>--scale-budget [출력파일] [컨트롤수]</c>.
///
/// <b>왜 필요한가.</b> --edit-smoke 는 컨트롤 <b>3개</b> 합성 문서에서 배선만 확인한다.
/// 운영 최대 서식은 1,067개다. 그 규모에서 무엇이 느린지 지금 CI 에 보이는 숫자가 하나도 없어서,
/// 재설계가 새 상호작용을 얹으면 <b>재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다</b>.
///
/// <b>무엇을 겨누는가.</b> 월드 좌표 조회가 O(N) 이고 그것이 O(N) 루프 안에 있다:
/// <c>PageOf</c> 가 <c>Pages.FirstOrDefault(p =&gt; p.Controls.Contains(root))</c> 이고
/// <c>ParentWorldOffset</c> → <c>WorldBoundsOf</c> 가 전부 이걸 탄다.
/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다.
///
/// <b>시간 예산은 넉넉하게 잡는다.</b> 이 숫자는 기계와 부하에 따라 흔들리므로,
/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라
/// <b>"사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"</b>이다.
/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 16ms, 한 번 하는 조작은 1초) 기준으로 둔다.
///
/// <b>재지 못하는 것.</b> 여기서 도는 것은 뷰모델 계층뿐이다 — 시각 트리가 없으므로
/// WPF 의 Measure/Arrange, 바인딩 재평가, 렌더가 빠져 있다.
/// 줌이 LayoutTransform 이라 눈금마다 서브트리 전체를 다시 재는 비용,
/// Undo 뒤 시각 트리를 통째로 다시 세우는 비용은 이 숫자에 들어 있지 않다.
/// 이 진단이 답하는 질문은 "상호작용 산술이 규모에서 터지는가"이고, 답은 아니다 —
/// 실측으로 O(N)-in-O(N) 경고가 과장이었음이 드러났다(§커밋 메시지 참조).
/// </summary>
internal static class ScaleBudget
{
#region Member Fields
/// <summary>프레임당 이 이상이면 60fps 를 못 지킨다 — 드래그가 끊겨 보인다</summary>
private const double FrameBudgetMs = 16;
/// <summary>한 번 하는 조작(마퀴 전체 선택, 되돌리기)이 이 이상이면 멈춘 것으로 보인다</summary>
private const double GestureBudgetMs = 1000;
private const int HoverFrames = 100;
private const int DragFrames = 60;
#endregion
#region Methods
public static int Run(string? outputPath, int controlCount)
{
var report = new StringBuilder();
var failures = 0;
report.AppendLine($"운영 규모 예산 — 컨트롤 {controlCount}개");
report.AppendLine();
var designer = BuildPage(controlCount);
var controls = designer.Pages[0].Controls;
report.AppendLine($"최상위 {controls.Count}개 + 중첩 자식 {controls.Sum(c => c.Model.Children.Count)}개,"
+ $" 페이지 {designer.Pages.Count}장");
if (controls.Count != controlCount)
{
report.AppendLine($"★ 만들려던 수와 다르다 — 이 상태의 숫자는 비교 대상이 못 된다");
failures++;
}
report.AppendLine();
var controller = new InteractionController(designer);
var none = new PointerContext(false, false, false, 1);
var ctrl = new PointerContext(true, false, false, 1);
// ① 호버 — 드래그가 아니어도 마우스만 움직이면 매 프레임 히트테스트가 돈다
failures += Budget(report, "호버(선택 0)", HoverFrames, FrameBudgetMs, frame =>
designer.Interaction.CursorAt(new Point(60 + (frame % 40), 60 + (frame % 40))));
// ② 드래그 1개 — 가장 흔한 조작
designer.Selection.SetSingle(controls[0]);
failures += Budget(report, "드래그(선택 1)", DragFrames, FrameBudgetMs, frame =>
{
if (frame == 0)
{
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
}
controller.PointerMove(new Point(controls[0].X + 6 + frame, controls[0].Y + 6 + frame), none);
});
controller.CancelDrag();
// ③ 드래그 다수 — 정렬·같은 크기·간격 균등이 겨냥하는 바로 그 상황이다
var many = Math.Min(500, controls.Count);
designer.Selection.Set(controls.Take(many).ToList());
var anchor = controls[0];
failures += Budget(report, $"드래그(선택 {many})", DragFrames, FrameBudgetMs, frame =>
{
if (frame == 0)
{
controller.PointerDown(new Point(anchor.X + 2, anchor.Y + 2), none);
}
controller.PointerMove(new Point(anchor.X + 6 + frame, anchor.Y + 6 + frame), none);
});
controller.CancelDrag();
designer.Selection.Clear();
// ④ 마퀴로 전부 선택 — 한 제스처 안에서 O(N²) 가 터지는 자리
failures += Budget(report, "마퀴 전체 선택", 1, GestureBudgetMs, _ =>
{
controller.PointerDown(new Point(-40, -40), ctrl);
controller.PointerMove(new Point(4000, 40000), ctrl);
controller.PointerUp(new Point(4000, 40000), ctrl);
});
report.AppendLine($" (마퀴로 잡힌 것 {designer.Selection.Items.Count}개)");
// ⑤ 되돌리기 — 문서 전체 딥클론 + VM 전체 재생성이라 앱에서 가장 비싼 키 한 방이다
designer.Selection.SetSingle(controls[0]);
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
controller.PointerMove(new Point(controls[0].X + 40, controls[0].Y + 40), none);
controller.PointerUp(new Point(controls[0].X + 40, controls[0].Y + 40), none);
failures += Budget(report, "되돌리기 1회", 1, GestureBudgetMs, _ => designer.Undo.Undo());
report.AppendLine();
report.AppendLine($"결과: 실패 {failures}건");
var text = report.ToString();
Console.Error.Write(text);
if (outputPath is { Length: > 0 })
{
System.IO.File.WriteAllText(outputPath, text);
}
return failures == 0 ? 0 : 1;
}
/// <summary>
/// 프레임당 평균으로 판정한다 — 최악값은 GC 한 번에 튀어서 거짓 실패를 낸다.
/// 최악값은 함께 적어 사람이 볼 수 있게만 남긴다.
/// </summary>
private static int Budget(StringBuilder report, string name, int frames, double budgetMs, Action<int> work)
{
// 첫 회는 JIT·지연 초기화가 섞여 본 측정과 성격이 다르다 — 버리는 대신 따로 적는다
var warmUp = Stopwatch.StartNew();
work(0);
warmUp.Stop();
var worst = 0.0;
var watch = Stopwatch.StartNew();
for (var frame = 0; frame < frames; frame++)
{
var one = Stopwatch.StartNew();
work(frame);
one.Stop();
worst = Math.Max(worst, one.Elapsed.TotalMilliseconds);
}
watch.Stop();
var average = watch.Elapsed.TotalMilliseconds / Math.Max(1, frames);
var ok = average <= budgetMs;
report.AppendLine($"{(ok ? "PASS" : "FAIL")} {name,-18} 평균 {average,8:0.00}ms"
+ $" (예산 {budgetMs}ms, 최악 {worst:0.00}ms, 첫회 {warmUp.Elapsed.TotalMilliseconds:0.00}ms, {frames}회)");
return ok ? 0 : 1;
}
/// <summary>
/// 운영 최대 규모의 페이지를 만든다. 컨테이너에 담긴 자식을 섞는다 —
/// 중첩 자식이 28,009개(그 중 RadioButton 15,026개)라 그것이 실제 모양이고,
/// 월드 좌표 조회가 부모를 타고 올라가는 비용도 거기서만 나온다.
/// </summary>
private static DesignerViewModel BuildPage(int count)
{
var document = new FormDocument();
var page = new FormPage { Width = 720, Height = 856 };
document.Pages.Add(page);
// <b>최상위를 정확히 count 개로 만든다.</b> O(N) 원시연산(PageOf 의 Controls.Contains,
// ControlsIntersecting)이 보는 N 은 최상위 개수이므로, 자식을 최상위에서 빼면 N 이 절반이 되어
// 최악을 재지 못한다(처음에 그렇게 만들어 534 를 재고 있었다).
// 컨테이너 자식은 그 위에 더 얹는다 — 중첩 자식 28,009개가 실제 모양이고,
// 월드 좌표가 부모를 타고 올라가는 비용은 거기서만 난다.
var panel = new ControlElement { Type = "Panel", Id = "Panel1" };
panel.Bounds = new LayoutRect { X = 20, Y = 20, W = 660, H = 400 };
page.Controls.Add(panel);
for (var i = 0; i < count / 2; i++)
{
panel.Children.Add(new ControlElement
{
Type = "RadioButton",
Id = $"N{i:0000}",
Bounds = new LayoutRect { X = 8 + (i % 40) * 16, Y = 8 + (i / 40 % 24) * 16, W = 60, H = 14 },
});
}
for (var i = 0; i < count - 1; i++)
{
page.Controls.Add(new ControlElement
{
Type = i % 3 == 0 ? "RadioButton" : i % 3 == 1 ? "Label" : "TextBox",
Id = $"C{i:0000}",
Bounds = new LayoutRect { X = 20 + (i % 40) * 17, Y = 440 + (i / 40 % 24) * 17, W = 60, H = 14 },
});
}
return new DesignerViewModel(document);
}
#endregion
}
@@ -50,6 +50,15 @@ public static class ConfigLoader
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
config.GridSize = ReadInt(root["Designer:GridSize"], 4);
config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6);
// 싸인 이미지 공유 매핑 — 배열(Images:SignatureShareMap:0…) 또는 세미콜론 한 줄 둘 다 받는다.
// 병원 배포는 ini·json 을 손으로 고치는 일이 많아 한 줄 형태가 실수를 줄인다.
config.SignatureShareMap = root.GetSection("Images:SignatureShareMap").GetChildren()
.Select(c => c.Value ?? string.Empty)
.Concat((root["Images:SignatureShareMap"] ?? string.Empty)
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
.Where(v => v.Contains('=', StringComparison.Ordinal))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToArray();
return config;
}
@@ -42,6 +42,12 @@ public sealed class InteractionController
private Rect groupBounds0;
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
private bool undoCaptured;
/// <summary>
/// 이번 드래그가 Ctrl+복사로 <b>사본을 만들었는가</b> — Esc 취소가 좌표만 되돌리면 안 되기 때문이다.
/// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다.
/// </summary>
private bool duplicatedThisDrag;
#endregion
#region Constructors
@@ -156,8 +162,29 @@ public sealed class InteractionController
if (!downControl.IsSelected)
{
var mates = designer.GroupMatesOf(downControl);
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
// Shift 는 이 제스처에서도 '더하기'다 — 기존 선택과 합친다(업 토글과 같은 규칙).
// Ctrl 은 '복사'라서 더하기가 아니다: 합집합으로 두면 아까 선택해 둔 컨트롤까지
// 같이 복제돼, 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다.
designer.Selection.Set(
ctx.Ctrl ? mates : designer.Selection.Items.Union(mates).ToList(), downControl);
}
// Ctrl+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 <b>복제본이 끌린다</b>.
//
// <b>왜 여기서 복제하나.</b> 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다.
// 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다.
//
// <b>왜 붙여넣기가 아닌가.</b> Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를
// 그렇게 복사하면 사본이 패널 밖으로 나가 <b>배타 그룹이 깨진다</b>(레거시 라디오는
// 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다.
if (ctx.Ctrl && designer.DuplicateSelectionInPlace())
{
// 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다.
// 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는
// 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다.
undoCaptured = true;
duplicatedThisDrag = true;
}
// 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다
BeginMove();
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
}
@@ -206,20 +233,21 @@ public sealed class InteractionController
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break;
case Mode.Resize:
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break;
case Mode.Marquee:
var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null);
var hits = designer.ControlsIntersecting(rect)
.SelectMany(designer.GroupMatesOf)
.Distinct()
.ToList();
// 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
// GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
var hits = designer.MarqueeHits(rect);
if (ctx.Ctrl || ctx.Shift)
{
var merged = designer.Selection.Items.Union(hits).ToList();
@@ -235,6 +263,7 @@ public sealed class InteractionController
mode = Mode.None;
downControl = null;
origBounds.Clear();
duplicatedThisDrag = false; // 이 드래그는 끝났다 — 다음 Esc 가 남의 복제를 물면 안 된다
designer.RefreshOverlay();
}
@@ -246,6 +275,29 @@ public sealed class InteractionController
{
if (mode is Mode.Move or Mode.Resize)
{
// Ctrl+드래그 복사를 취소하는 경우 — 좌표를 되돌리는 것으로는 부족하다.
// 사본은 이미 <b>문서에 들어가 있고</b>, 원래 자리로 되돌리면 원본과 픽셀 단위로 겹쳐
// 화면은 취소된 것처럼 보인다. 그대로 저장하면 종이에 같은 글자가 두 번 인쇄되고
// 증거는 "조금 굵어 보인다" 뿐이다. 그래서 복제 자체를 물린다.
//
// 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이
// 죽은 참조가 된다. 상태를 <b>먼저</b> 비우고 그다음에 되돌린다.
if (duplicatedThisDrag)
{
designer.Overlay.ClearGuides();
designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
mode = Mode.None;
origBounds.Clear();
downControl = null;
duplicatedThisDrag = false;
undoCaptured = false;
designer.Undo.Undo();
// 취소는 "없던 일로" 다 — Ctrl+Y 로 사본이 되살아나면 취소가 아니다
designer.Undo.DiscardRedo();
designer.RefreshOverlay();
return;
}
foreach (var (vm, rect) in origBounds)
{
var offset = designer.ParentWorldOffset(vm);
@@ -277,7 +329,13 @@ public sealed class InteractionController
}
if (mode == Mode.None)
{
designer.Selection.Clear();
// 컨테이너 안에 들어가 있으면 먼저 한 겹 나간다 — Enter 로 들어가고 Esc 로 나오는 짝이다.
// 나갈 곳이 없을 때만 선택을 푼다(전에는 항상 풀어서, 패널 자식을 고른 뒤 Esc 를 누르면
// 부모로 돌아가는 대신 선택이 통째로 사라졌다).
if (!designer.SelectParent())
{
designer.Selection.Clear();
}
return;
}
CancelDrag();
@@ -387,6 +445,11 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
// 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
if (designer.SelectionWorldBounds() is { } movedBounds)
{
designer.Overlay.SetDragBadge(movedBounds, resizing: false);
}
}
private void DoResize(Point world, PointerContext ctx)
@@ -453,6 +516,10 @@ public sealed class InteractionController
}
designer.Selection.NotifyBoundsChanged();
if (designer.SelectionWorldBounds() is { } sizedBounds)
{
designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
}
}
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
@@ -0,0 +1,172 @@
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>한 MDataTable 의 실행 결과 — 또는 왜 실행하지 못했는지</summary>
/// <param name="Name">데이터소스 컨트롤 이름(배선이 이 이름으로 찾는다)</param>
/// <param name="Sql">치환을 마친 SQL — 사람이 눈으로 확인해야 하는 그 문장</param>
/// <param name="Columns">결과 컬럼</param>
/// <param name="Rows">결과 행</param>
/// <param name="Error">실패 사유. null 이면 성공</param>
/// <param name="Unknown">치환하지 못한 변수들 — 값이 빈 이유가 여기 있을 수 있다</param>
public sealed record MDataTableResult(
string Name, string Sql,
IReadOnlyList<string> Columns, IReadOnlyList<string[]> Rows,
string? Error, IReadOnlyList<string> Unknown)
{
public bool Ok => Error is null;
}
/// <summary>
/// 미리보기에서 MDataTable 을 <b>실제로 돌린다</b> — 레거시 <c>ucLoadSheetBase</c> 가
/// 서식을 로드할 때 하던 일이다.
///
/// <b>왜 필요한가.</b> 서식의 값 상당수는 태그가 아니라 이 경로로 온다. 태그만 채워 보면
/// "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
///
/// <b>실행은 읽기만.</b> <see cref="OracleQueryWorkbench.Trial"/> 을 그대로 쓴다 —
/// SELECT/WITH 만 통과시키는 문이 이미 거기 있고, 이 접속은 운영에 붙을 수 있다.
/// 문을 두 군데 두면 한쪽이 느슨해진다.
///
/// <b>치환 값이 SQL 에 원문 그대로 박힌다</b>(레거시가 그렇다 — 그래야 같은 SQL 이 된다).
/// 따옴표가 섞인 값은 <see cref="QuerySubstitutionResult.QuotedValues"/> 로 드러내고
/// 실행은 SELECT/WITH 문이 막는다.
///
/// <b>한 번만 돌린다.</b> 컨트롤마다 돌리면 배선 40개짜리 서식에 왕복 40회가 된다 —
/// 데이터소스 단위로 캐시한다. 환자를 바꾸면 러너를 새로 만든다(같은 러너를 재사용하면
/// 옛 환자의 표가 남는다).
/// </summary>
public sealed class MDataTableRunner : IDataFieldResolver
{
#region Member Fields
private readonly IQueryVariableSource variables;
private readonly OracleQueryWorkbench? workbench;
private readonly Dictionary<string, MDataTableResult> cache = new(StringComparer.OrdinalIgnoreCase);
/// <summary>이름 → 그 데이터소스의 쿼리 원문</summary>
private readonly Dictionary<string, string> queries = new(StringComparer.OrdinalIgnoreCase);
#endregion
#region Constructors
/// <param name="document">데이터소스와 그 쿼리를 여기서 모은다</param>
/// <param name="variables">치환 값을 내주는 쪽 — 환자를 안 골랐으면 null</param>
public MDataTableRunner(FormDocument document, IQueryVariableSource? variables)
{
this.variables = variables ?? EmptySource.Instance;
var connection = ConfigService.Current.ConnectionString;
workbench = connection.Length > 0 ? new OracleQueryWorkbench(connection) : null;
foreach (var page in document.Pages)
{
Collect(page.Controls);
}
}
#endregion
#region Methods
/// <summary>이름이 붙은 데이터소스들 — 화면이 결과를 목록으로 보여 줄 때 쓴다</summary>
public IReadOnlyList<string> Names => queries.Keys.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
/// <summary>환자 문맥이 붙어 있는가 — 없으면 치환이 전부 빈 값이 되어 결과가 무의미하다</summary>
public bool HasContext => !ReferenceEquals(variables, EmptySource.Instance);
/// <summary>이 데이터소스를 돌린 결과 — 처음 물을 때 한 번만 실행한다</summary>
public MDataTableResult Run(string name)
{
if (cache.TryGetValue(name, out var cached))
{
return cached;
}
var result = Execute(name);
cache[name] = result;
return result;
}
/// <summary>모두 돌린다 — 미리보기가 결과를 한꺼번에 보여 줄 때</summary>
public IReadOnlyList<MDataTableResult> RunAll() => Names.Select(Run).ToList();
public TagValue Resolve(DataTableFieldSpec spec)
{
if (!queries.ContainsKey(spec.TableName))
{
// 배선이 없는 데이터소스를 가리킨다 — 레거시 런타임은 빈 catch 로 삼켜서
// 아무 표시 없이 빈칸이 된다. 여기서는 이름을 말한다.
return new TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
}
var run = Run(spec.TableName);
if (!run.Ok)
{
return new TagValue(false, run.Error ?? "조회에 실패했습니다");
}
// 행 선택(Rows 형 번호 / Select 형 .NET DataTable.Select 필터)은
// 순수 함수에 있다 — DB 없이 검증할 수 있어야 진단이 접속에 안 묶인다
return DataTableFieldPicker.Pick(run.Columns, run.Rows, spec);
}
private MDataTableResult Execute(string name)
{
var sqlRaw = queries.TryGetValue(name, out var q) ? q : string.Empty;
if (sqlRaw.Trim().Length == 0)
{
return new MDataTableResult(name, string.Empty, Array.Empty<string>(),
Array.Empty<string[]>(), "쿼리가 비어 있습니다", Array.Empty<string>());
}
var converted = QuerySubstitution.Apply(sqlRaw, variables);
if (converted.HasLeftover)
{
// 접두어가 클래스 전체 이름이 아니면 토큰이 SQL 에 남는다 — 실행하면 ORA 구문오류다.
// 미리 말해 준다. 오라클 오류 문구보다 이쪽이 고칠 곳을 알려 준다.
return new MDataTableResult(name, converted.Sql, Array.Empty<string>(), Array.Empty<string[]>(),
"치환되지 않은 변수가 남아 있습니다(접두어가 클래스 전체 이름이어야 합니다): "
+ string.Join(", ", converted.Hits
.Where(h => h.Kind == QuerySubstitutionKind.NotAVariable)
.Select(h => h.Token).Distinct()),
converted.UnknownTokens);
}
if (workbench is null)
{
return new MDataTableResult(name, converted.Sql, Array.Empty<string>(),
Array.Empty<string[]>(), "DB 에 접속되어 있지 않습니다", converted.UnknownTokens);
}
var trial = workbench.Trial(converted.Sql);
return new MDataTableResult(name, converted.Sql, trial.Columns, trial.Rows,
trial.Ok ? null : trial.Error ?? "조회에 실패했습니다", converted.UnknownTokens);
}
/// <summary>데이터소스를 재귀로 모은다 — 패널·그룹박스 안에 있는 것도 배선 대상이다</summary>
private void Collect(IEnumerable<ControlElement> controls)
{
foreach (var control in controls)
{
// <b>중립 타입명으로 비교한다.</b> 여기가 레거시 클래스명("MDataTable")을 보고 있어서
// 실사용 서식에서는 데이터소스를 <b>한 개도 못 모았다</b> — XML 에서 읽는 순간
// LegacyTypeCatalog 가 이미 "DataTable" 로 바꿔 두기 때문이다.
// 그 결과 모든 DataTableField 배선이 "데이터소스 X 이 이 서식에 없습니다"로 실패해
// 미리보기·인쇄에서 빈칸이 됐고, 미리보기의 '데이터소스...' 버튼은 영영 회색이었다.
// 진단들이 ControlElement 를 손으로 만들며 같은 레거시 이름을 써서 초록으로 통과하고 있었다.
if (string.Equals(control.Type, LegacyTypeCatalog.DataTableType, StringComparison.Ordinal))
{
queries[control.Id] = control.Props.GetText("Query") ?? string.Empty;
}
if (control.Children.Count > 0)
{
Collect(control.Children);
}
}
}
/// <summary>환자를 안 골랐을 때 — 모든 변수를 "모른다"로 답한다</summary>
private sealed class EmptySource : IQueryVariableSource
{
public static readonly EmptySource Instance = new();
public string? Scalar(string className, string property) => null;
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
}
#endregion
}
+169
View File
@@ -0,0 +1,169 @@
using System.Windows;
using System.Windows.Media;
namespace SheetMe.Designer.Services;
/// <summary>
/// 모달 대화상자 뒤를 어둡게 덮는다.
///
/// <b>왜 필요한가.</b> WPF 의 <c>ShowDialog()</c> 는 입력만 막고 화면은 그대로 둔다.
/// 대화상자가 앱과 같은 밝기로 떠 있으면 "이게 떠 있는 동안은 뒤를 못 만진다"가 안 읽혀서,
/// 뒤를 클릭했다가 아무 반응이 없으면 멈춘 것으로 오해한다.
///
/// <b>왜 호출부마다 안 고쳤나.</b> ShowDialog 호출부가 17곳이고 앞으로 더 는다.
/// 한 곳이라도 빠지면 그 대화상자만 다르게 동작하는데, 그건 눈으로 전수 확인해야만 알 수 있다.
/// 창이 뜨는 순간을 전역으로 잡아 <b>모달일 때만</b> 건다(WindowChromeTheme 이 제목 표시줄 색을
/// 같은 방식으로 거는 것과 같은 자리다).
///
/// 진단 모드는 창을 모달이 아니라 화면 밖에 그냥 띄우므로 이 경로를 타지 않는다 —
/// --dialog-shots 산출물은 영향받지 않는다.
/// </summary>
public static class ModalScrim
{
#region Member Fields
/// <summary>지금 떠 있는 가림막 — 대화상자가 겹쳐 뜨면 그만큼 쌓인다</summary>
private static readonly Stack<Window> Layers = new();
#endregion
#region Methods
/// <summary>
/// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 <b>보이기 시작할 때</b> 불러야 한다.
/// </summary>
/// <summary>
/// 이 창이 뒤를 어둡게 할 자격이 있는가 — <b>기본값은 아니다</b>.
///
/// 전에는 모든 <c>ShowDialog</c> 에 무조건 붙었다. 그래서 태그 하나 고르는 데도 앱이 암전했고,
/// 하루 수백 번 그러면 그건 신호가 아니라 소음이다.
///
/// 암전이 옳은 자리는 <b>지금 멈추고 결정해야 하는 곳</b>뿐이다 —
/// 되돌릴 수 없는 쓰기의 확인(운영 EMR 테이블에 버전을 만든다), 진행이 정의되지 않는 차단 통보.
/// 값 하나를 고르는 일은 Undo 로 되돌아가고 판단 근거가 뒤 화면에 있으므로 가리면 안 된다.
///
/// 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고,
/// 그건 눈으로 전수 확인해야만 알 수 있다. 대신 <b>기본을 뒤집었다</b>:
/// 후크는 항상 돌지만, 창이 스스로 자격을 밝혀야 깔린다.
/// 새 대화상자가 이것을 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다.
/// </summary>
public static readonly DependencyProperty DimBehindProperty =
DependencyProperty.RegisterAttached("DimBehind", typeof(bool), typeof(ModalScrim),
new PropertyMetadata(false));
public static void SetDimBehind(DependencyObject target, bool value)
=> target.SetValue(DimBehindProperty, value);
public static bool GetDimBehind(DependencyObject target)
=> (bool)target.GetValue(DimBehindProperty);
public static void Attach(Window window)
{
// 모달 판정을 여기서 바로 하면 안 된다. ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다)
// <b>그다음에</b> 모달 루프에 들어가므로, 이 시점의 IsThreadModal 은 아직 false 다.
// 실제로 그렇게 만들어서 가림막이 한 번도 안 깔렸다(--modal-check 가 그 상태를 잡았다).
// 모달 프레임이 돌기 시작한 뒤에 다시 본다 — 한 프레임 늦게 깔리는 것은 눈에 띄지 않는다.
window.Dispatcher.BeginInvoke(new Action(() => AttachIfModal(window)),
System.Windows.Threading.DispatcherPriority.Background);
}
private static void AttachIfModal(Window window)
{
// 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다.
// 그리고 스스로 자격을 밝힌 창만 깐다 — 값을 고르는 대화상자는 뒤를 가리면 안 된다.
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible
|| !GetDimBehind(window))
{
return;
}
var behind = window.Owner ?? Application.Current?.MainWindow;
if (behind is null || ReferenceEquals(behind, window) || !behind.IsVisible)
{
return;
}
var scrim = new Window
{
WindowStyle = WindowStyle.None,
ResizeMode = ResizeMode.NoResize,
AllowsTransparency = true,
Background = new SolidColorBrush(Color.FromArgb(0x66, 0, 0, 0)),
ShowInTaskbar = false,
ShowActivated = false,
IsHitTestVisible = false,
Focusable = false,
WindowStartupLocation = WindowStartupLocation.Manual,
Owner = behind,
};
Cover(scrim, behind);
// 뒤 창이 움직이거나 크기가 바뀌면 가림막도 따라가야 한다 — 안 그러면 어두운 사각형만 남는다
void Follow(object? sender, EventArgs e) => Cover(scrim, behind);
behind.LocationChanged += Follow;
behind.SizeChanged += Follow;
behind.StateChanged += Follow;
scrim.Show();
// 가림막을 대화상자 <b>바로 아래</b>로 내린다.
//
// Activate() 로는 안 된다. 둘 다 같은 창을 소유자로 갖는 형제라 나중에 띄운 쪽이 위에 오고,
// 활성 창을 바꾸는 것과 z순서를 바꾸는 것은 다른 일이다 —
// 실제로 대화상자까지 같이 흐려졌다.
PlaceBehind(scrim, window);
Layers.Push(scrim);
window.Closed += (_, _) =>
{
behind.LocationChanged -= Follow;
behind.SizeChanged -= Follow;
behind.StateChanged -= Follow;
if (Layers.Count > 0)
{
Layers.Pop();
}
scrim.Close();
};
}
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
private static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter,
int x, int y, int cx, int cy, uint flags);
/// <summary>
/// <paramref name="scrim"/> 을 <paramref name="above"/> 바로 아래로 놓는다.
/// SetWindowPos 의 insertAfter 는 "이 창 다음"이라는 뜻이라, 대화상자를 넘기면 그 아래가 된다.
/// </summary>
private static void PlaceBehind(Window scrim, Window above)
{
var scrimHandle = new System.Windows.Interop.WindowInteropHelper(scrim).Handle;
var aboveHandle = new System.Windows.Interop.WindowInteropHelper(above).Handle;
if (scrimHandle == IntPtr.Zero || aboveHandle == IntPtr.Zero)
{
return;
}
// SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE
SetWindowPos(scrimHandle, aboveHandle, 0, 0, 0, 0, 0x0002 | 0x0001 | 0x0010);
}
/// <summary>뒤 창을 정확히 덮는다 — 최대화 상태도 그 사각형 그대로다</summary>
private static void Cover(Window scrim, Window behind)
{
if (behind.WindowState == WindowState.Minimized)
{
scrim.Visibility = Visibility.Collapsed;
return;
}
scrim.Visibility = Visibility.Visible;
// 최대화된 창은 Left/Top 이 '복원했을 때의 값'이라 그대로 쓰면 가림막이 엉뚱한 데 깔린다.
// 화면 좌표를 직접 물어 DIP 로 되돌린다.
var origin = new Point(behind.Left, behind.Top);
if (PresentationSource.FromVisual(behind) is { CompositionTarget: { } target })
{
origin = target.TransformFromDevice.Transform(behind.PointToScreen(new Point(0, 0)));
}
scrim.Left = origin.X;
scrim.Top = origin.Y;
scrim.Width = behind.ActualWidth > 0 ? behind.ActualWidth : behind.Width;
scrim.Height = behind.ActualHeight > 0 ? behind.ActualHeight : behind.Height;
}
#endregion
}
@@ -0,0 +1,320 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 보정을 마친 주민번호를 만드는 <b>유일한 자리</b>.
///
/// 왜 한 곳으로 좁히는가: 이 값은 이 앱이 다루는 것 중 가장 민감하다.
/// 만드는 경로가 여러 곳이면 어디서 새는지 추적할 수 없고, 보정 순서가 어긋난 사본이 생긴다.
///
/// <b>값을 저장하지 않는다.</b> 필요할 때 만들어 <see cref="PatientTagResolver"/> 에 넘기고 끝낸다.
/// 로그에도 남기지 않는다 — 어떤 진단 리포트에도 값이 아니라 <b>길이</b>만 적는다.
///
/// 복호화는 DB 가 한다(<see cref="PersonalDataStore"/>). 앱은 키를 갖지 않는다.
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
/// </summary>
public static class PatientIdentity
{
#region Methods
/// <summary>
/// 이 환자의 주민번호 — 못 만들면 빈 문자열.
///
/// <b>암호화 설정을 못 읽으면 만들지 않는다.</b> 설정을 "암호화 아님"으로 가정하면
/// <c>PatResNum</c> 을 평문처럼 써서 암호문을 종이에 찍는다. 빈칸이 훨씬 낫다.
/// </summary>
public static string ResidentNumberOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
if (connection.Length == 0)
{
return string.Empty;
}
var hospital = UserSession.Current.HspCod;
try
{
var store = new PersonalDataStore(connection);
var flags = store.Flags();
var raw = flags.ResidentNumberEncrypted
// 암호화 병원 — PatResNum 은 쓰지 않는다. PatResEnc 를 DB 가 풀어 준다
? store.Decrypt(PatientContext.Value(context.PatInf, "PatResEnc"), hospital)
: PatientContext.Value(context.PatInf, "PatResNum");
return ResidentNumber.Normalize(
raw,
PatientContext.Value(context.PatInf, "PatBthDay"),
PatientContext.Value(context.PatInf, "PatNatCod"),
hospital);
}
catch (Exception ex)
{
// 사유는 남기지만 값은 남기지 않는다 — 예외 메시지에 SQL 이 섞여도 값은 바인드였다
AppLog.Audit($"[주민번호] 만들지 못했습니다: {ex.GetType().Name}");
return string.Empty;
}
}
/// <summary>
/// 이 내원의 담당의 행 — 못 읽으면 빈 사전.
///
/// 어느 시각의 진료과를 볼지는 레거시 <c>OCM_담당의사</c> 가 세 갈래로 정한다
/// (bzDataInterface.vb):
/// · 입원이고 <c>ComPrgStt='ILV'</c>(퇴원) → 퇴원일시
/// · 입원이고 그 밖 → 지금(= 문맥이 정한 적용일시)
/// · 외래 → 접수일시
/// 담당의 유효기간을 볼 날짜도 갈린다 — 입원은 진료과 행의 <c>CodEndDtm</c>, 외래는 접수일자.
/// </summary>
public static IReadOnlyDictionary<string, string> DoctorOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (connection.Length == 0)
{
return empty;
}
var inpatient = PatientContext.Value(context.ComInf, "ComPatTyp") == "I";
var accepted = PatientContext.Value(context.ComInf, "ComAcpDtm");
var left = PatientContext.Value(context.ComInf, "ComLevDtm");
var discharged = PatientContext.Value(context.ComInf, "ComPrgStt") == "ILV";
var moment = inpatient
? (discharged && left.Length >= 12 ? left[..12] : context.AdpDtm)
: (accepted.Length >= 12 ? accepted[..12] : context.AdpDtm);
// 입원은 진료과 행의 종료일시로 담당의 유효기간을 본다 — 그 값은 저장소가 행을 읽은 뒤에야 안다.
// 여기서는 외래만 날짜를 지정하고, 입원은 저장소가 적용일시에서 뽑게 둔다.
var doctorDay = inpatient ? null : (accepted.Length >= 8 ? accepted[..8] : null);
try
{
return new PatientContextStore(connection).Doctor(context.ComNum, moment, doctorDay);
}
catch
{
return empty;
}
}
/// <summary>
/// 주치의(전공의) 행 — 진료과 행의 <b>CodStfDtr</b> 로 찾는다. 시각 갈래는 담당의와 같다
/// (bzDataInterface.vb:4026-4067). 코드가 비어 있으면 빈 사전 — 레거시도 즉시 "" 다.
/// </summary>
public static IReadOnlyDictionary<string, string> StaffDoctorOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (connection.Length == 0)
{
return empty;
}
var inpatient = PatientContext.Value(context.ComInf, "ComPatTyp") == "I";
var accepted = PatientContext.Value(context.ComInf, "ComAcpDtm");
var left = PatientContext.Value(context.ComInf, "ComLevDtm");
var discharged = PatientContext.Value(context.ComInf, "ComPrgStt") == "ILV";
var moment = inpatient
? (discharged && left.Length >= 12 ? left[..12] : context.AdpDtm)
: (accepted.Length >= 12 ? accepted[..12] : context.AdpDtm);
var doctorDay = inpatient ? null : (accepted.Length >= 8 ? accepted[..8] : null);
try
{
var row = new PatientContextStore(connection)
.Doctor(context.ComNum, moment, doctorDay, "CodStfDtr");
// 코드가 비어 있으면 Doctor 가 진료과 행을 돌려준다 — 주치의 행이 아니므로 비운다
return row.ContainsKey("UidNam") ? row : empty;
}
catch
{
return empty;
}
}
/// <summary>
/// 상병 행 공급자 — 해석기가 <b>필요할 때</b> 갈래(전체/주/부)별로 부른다.
/// 여기서 미리 읽지 않는 이유는 상병 태그가 없는 서식이 다수이기 때문이다.
/// 실패는 던진다 — 해석기가 잡아 사유로 바꾼다(조용히 빈 목록을 주면
/// "상병이 없다"와 "조회가 죽었다"를 화면에서 못 가른다).
/// </summary>
public static Func<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>>
DiagnosisSourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return scope => connection.Length == 0
? new List<Dictionary<string, string>>()
: new DiagnosisStore(connection).Diagnoses(context.ComNum, scope);
}
/// <summary>심사 상병(B_OkdInf) 공급자 — 상병과 같은 정책</summary>
public static Func<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>>
ReviewDiagnosisSourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return scope => connection.Length == 0
? new List<Dictionary<string, string>>()
: new ReviewDiagnosisStore(connection).Diagnoses(context.ComNum, scope);
}
/// <summary>바이탈 공급자 — 태그가 물을 때 한 값씩 읽는다(측정 태그 없는 서식이 다수다)</summary>
public static Func<string, string, string[], string, string> VitalSourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return (expr, pick, notNull, order) => connection.Length == 0
? string.Empty
: new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order);
}
/// <summary>BST 공급자 — 표가 달라(E_EmdInf_BST) 바이탈과 분리</summary>
public static Func<string, string> BstSourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return order => connection.Length == 0
? string.Empty
: new VitalStore(connection).OneBst(context.ComNum, order);
}
/// <summary>차트 전체 기준 바이탈 공급자 — BMI·표준·조정체중이 쓴다</summary>
public static Func<string, string, string[], string, string> ChartVitalSourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var chtNum = PatientContext.Value(context.ComInf, "ComChtNum");
return (expr, pick, notNull, order) => connection.Length == 0 || chtNum.Length == 0
? string.Empty
: new VitalStore(connection).OneByChart(chtNum, expr, pick, notNull, order);
}
/// <summary>병리판독의·진단검사의 공급자 — 환자와 무관하지만 환자 태그로 노출된다</summary>
public static Func<IReadOnlyDictionary<string, string>> PathologyDoctorSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new PatientContextStore(connection).PathologyDoctor();
}
public static Func<IReadOnlyDictionary<string, string>> LabDoctorSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new PatientContextStore(connection).LabDoctor();
}
/// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 연결이 없으면 null(해석기가 사유로 말한다)</summary>
public static Func<PatientExtraStore?> ExtraStoreSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0 ? null : new PatientExtraStore(connection);
}
/// <summary>싸인·이미지 경로 조회 공급자 — 파일서버 전송은 옮기지 않았고 경로만 얻는다</summary>
public static Func<SignatureStore?> SignatureStoreSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0 ? null : new SignatureStore(connection);
}
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path)</summary>
public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath();
/// <summary>파일서버 공유 매핑 — 설정(Images:SignatureShareMap)</summary>
public static IReadOnlyList<string> ShareMap() => ConfigService.Current.SignatureShareMap;
/// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
public static Func<PatientContextStore?> ContextStoreSource()
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0 ? null : new PatientContextStore(connection);
}
/// <summary>수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회</summary>
public static Func<bool, IReadOnlyDictionary<string, string>> ScheduledSurgerySourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return latest => connection.Length == 0
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
: new PatientContextStore(connection).ScheduledSurgery(context.ComNum, latest);
}
/// <summary>수술 행 공급자 — 상병과 같은 정책(필요할 때 1회, 실패는 던져 사유로)</summary>
public static Func<IReadOnlyList<Dictionary<string, string>>> SurgerySourceFor(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
return () => connection.Length == 0
? new List<Dictionary<string, string>>()
: new SurgeryStore(connection).Surgeries(context.ComNum);
}
/// <summary>
/// 이 내원의 진료과 마스터 행 — 못 읽으면 빈 사전.
/// 진료과 코드는 문맥의 CodInf(적용일시로 고른 행)에서, 유효기간 일자는
/// 레거시대로 <b>접수일자</b>다(bzDataInterface.vb:5445 — 입원·외래 무관).
/// </summary>
public static IReadOnlyDictionary<string, string> DepartmentOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (connection.Length == 0)
{
return empty;
}
try
{
return new PatientContextStore(connection).Department(
PatientContext.Value(context.CodInf, "CodDepCod"),
PatientContext.Value(context.ComInf, "ComAcpDtm"));
}
catch
{
return empty;
}
}
/// <summary>
/// 요양기관 행 — 못 읽으면 빈 사전.
/// 판정 일자가 진료과와 <b>다르다</b>(bzDataInterface.vb:15815-15825):
/// 입원·퇴원(ILV)은 퇴원일시, 입원 중은 지금, 외래는 접수일시 — 문맥의 적용일시가 그 판정과 같다.
/// </summary>
public static IReadOnlyDictionary<string, string> HospitalRowOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (connection.Length == 0)
{
return empty;
}
try
{
return new PatientContextStore(connection).HospitalRow(
PatientContext.Value(context.CodInf, "CodDepCod"), context.AdpDtm);
}
catch
{
return empty;
}
}
/// <summary>
/// 이 환자의 주소 조각들(구역번호·도로명·지번·영문) — 못 읽으면 빈 사전.
///
/// 주민번호와 같은 이유로 <b>환자를 붙일 때 한 번만</b> 읽는다 —
/// 주소 태그가 넷이라 태그마다 읽으면 왕복이 넷이 된다.
/// 실패해도 던지지 않는다: 호출부가 상세주소만으로 값을 만들 수 있고, 레거시도 그렇게 한다.
/// </summary>
public static IReadOnlyDictionary<string, string> AddressOf(PatientContext context)
{
var connection = ConfigService.Current.ConnectionString;
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
if (connection.Length == 0)
{
return empty;
}
try
{
return new PatientContextStore(connection)
.Address(PatientContext.Value(context.PatInf, "PatZipBdg"));
}
catch
{
return empty;
}
}
#endregion
}
@@ -0,0 +1,110 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 환자 문맥을 MDataTable 쿼리의 치환 변수로 내준다 — 레거시가 <c>bzPatientInfo</c> 인스턴스를
/// 넘기던 자리다.
///
/// <b>DataRow 접근형이 이 기능의 대부분을 실어 준다.</b>
/// <c>PatInfDR.item("아무컬럼")</c> 은 <see cref="PatientContext"/> 의 다섯 사전을 그대로 조회하면
/// 되고, 그 사전은 <c>SELECT *</c> 결과라 컬럼을 내가 미리 알 필요가 없다 —
/// <b>짐작할 것이 없으니 조용히 틀릴 일도 없다.</b>
///
/// 스칼라 속성도 대부분 그 다섯 행의 컬럼 하나다. 그래서 표에 컬럼 이름만 적고
/// <b>있으면 값, 없으면 모른다</b>고 답한다. 컬럼이 실제로 있는지는 DB 가 판정한다.
///
/// 산출값(Age·Sex 등 UDF 를 거치는 것)은 넣지 않았다 — 계산식을 짐작하면 값이 조용히 틀린다.
/// 넣지 않은 것은 <see cref="QuerySubstitutionKind.Unknown"/> 으로 드러난다.
/// </summary>
public sealed class PatientQueryVariableSource : IQueryVariableSource
{
#region Member Fields
/// <summary>스칼라 속성 → (문맥 행, 컬럼). 컬럼 존재 여부는 DB 결과가 판정한다</summary>
private static readonly Dictionary<string, (string Row, string Column)> ColumnScalars =
new(StringComparer.Ordinal)
{
["ChtNum"] = ("ComInf", "ComChtNum"),
["PatTyp"] = ("ComInf", "ComPatTyp"),
["ComCvtCom"] = ("ComInf", "ComCvtCom"),
["PatMblPhn"] = ("PatInf", "PatMblPhn"),
["ComDayCar"] = ("ComInf", "ComDayCar"),
};
private readonly PatientContext context;
private readonly string uidCod;
#endregion
#region Constructors
public PatientQueryVariableSource(PatientContext context, string uidCod)
{
this.context = context;
this.uidCod = uidCod;
}
#endregion
#region Methods
public string? Scalar(string className, string property)
{
if (className == QuerySubstitution.WorkClass)
{
// 작업 정보는 로그인 사용자와 적용일시뿐이다 — 레거시도 이 둘이 실사용의 전부다
return property switch
{
"WrkUid" => uidCod,
"AdpDtm" => context.AdpDtm,
_ => null,
};
}
if (className != QuerySubstitution.PatientClass)
{
// 서식 정보(bzSheetInfo)는 아직 붙이지 않았다 — 모른다고 말한다
return null;
}
// 계산해서 들고 있는 값이 먼저다. 이 둘은 컬럼이 아니라 판정 결과다
// (적용일시는 VisitMoment 가 여섯 갈래로 정한다).
if (property == "ComNum")
{
return context.ComNum.ToString("F0");
}
if (property is "AdpDtm" or "OrderAdpDtm")
{
return context.AdpDtm;
}
if (!ColumnScalars.TryGetValue(property, out var map))
{
return null;
}
var row = RowOf(map.Row);
// 행 자체가 없으면(외래의 병실 등) 값이 빈 것이다 — 속성을 모르는 것과 다르다
return row.TryGetValue(map.Column, out var value) ? value : row.Count == 0 ? string.Empty : null;
}
public IReadOnlyDictionary<string, string>? Row(string className, string property)
{
if (className != QuerySubstitution.PatientClass)
{
return null;
}
return property switch
{
"PatInfDR" => context.PatInf,
"ComInfDR" => context.ComInf,
"CodInfDR" => context.CodInf,
"CoiInfDR" => context.CoiInf,
"CowInfDR" => context.CowInf,
_ => null,
};
}
private IReadOnlyDictionary<string, string> RowOf(string name) => name switch
{
"PatInf" => context.PatInf,
"ComInf" => context.ComInf,
"CodInf" => context.CodInf,
"CoiInf" => context.CoiInf,
_ => context.CowInf,
};
#endregion
}
@@ -0,0 +1,86 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 지금 고른 환자 — 앱 전체가 하나를 공유한다.
///
/// <b>왜 전역인가.</b> 환자는 미리보기 창에서 고르는데, 그 값이 필요한 곳은 거기만이 아니다 —
/// 쿼리 편집기의 '검증 실행'도 같은 환자로 돌려야 의미가 있다.
/// 창마다 따로 들고 있으면 <b>같은 서식을 두 화면에서 서로 다른 환자로</b> 보게 되고,
/// 그건 화면으로 구분할 수 없다.
///
/// <b>하나만 둔다.</b> 여러 개를 허용하면 어느 것이 지금 것인지 알 수 없고,
/// 환자 정보가 걸린 문제에서 그건 개인정보 사고가 된다.
///
/// 앱이 닫힐 때까지 메모리에 남는다. 디스크에 쓰지 않는다 —
/// 환자 문맥은 로그에도 안 남기기로 한 값이다(<see cref="PatientPreviewPolicy.AuditLine"/>).
/// </summary>
public static class PatientSession
{
#region Member Fields
/// <summary>환자가 바뀌었다 — 열려 있는 화면들이 다시 그려야 한다</summary>
public static event EventHandler? Changed;
#endregion
#region Properties
/// <summary>지금 고른 환자의 문맥 — 안 골랐으면 null</summary>
public static PatientContext? Current { get; private set; }
/// <summary>사람에게 보여 줄 한 줄 — 창 머리에 그대로 쓴다</summary>
public static string Label { get; private set; } = string.Empty;
/// <summary>고른 환자가 있는가</summary>
public static bool HasPatient => Current is not null;
#endregion
#region Methods
/// <summary>환자를 붙이거나(문맥 전달) 뗀다(null 전달)</summary>
public static void Set(PatientContext? context, string label)
{
Current = context;
Label = context is null ? string.Empty : label;
Changed?.Invoke(null, EventArgs.Empty);
}
/// <summary>
/// 치환 변수원 — 환자가 없으면 null 이고, 호출부는 그때 자리표시로 구문만 검사해야 한다.
/// </summary>
public static IQueryVariableSource? Variables()
=> Current is { } context
? new PatientQueryVariableSource(context, UserSession.Current.UidCod)
: null;
/// <summary>
/// 이 문서용 태그·배선 해석기 한 벌 — 지금 세션 환자 기준.
///
/// 미리보기를 거치지 않는 인쇄(Ctrl+P)가 쓴다. 미리보기 창은 자기 해석기를 창 수명만큼
/// 들고 있지만, 여기는 <b>한 번 뽑는 종이</b>라 그때그때 만든다.
/// 주민번호·주소·담당의를 이 자리에서 한 번씩 읽는다 — 태그마다 읽으면 왕복이 늘어난다.
/// </summary>
public static (SheetMe.Core.Catalog.ITagValueResolver Tags, MDataTableRunner Fields)
ResolversFor(SheetMe.Core.Models.FormDocument document)
{
var session = new SessionTagResolver();
SheetMe.Core.Catalog.ITagValueResolver tags = Current is { } picked
? new PatientTagResolver(picked, session,
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.StaffDoctorOf(picked),
PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalRowOf(picked),
PatientIdentity.DiagnosisSourceFor(picked),
PatientIdentity.SurgerySourceFor(picked),
PatientIdentity.ReviewDiagnosisSourceFor(picked),
PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap())
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
#endregion
}
File diff suppressed because it is too large Load Diff
+224 -10
View File
@@ -16,20 +16,46 @@ public static class PrintService
{
#region Methods
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
public static void Print(DesignerViewModel designer, string documentName)
public static void Print(DesignerViewModel designer, string documentName,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var dialog = new System.Windows.Controls.PrintDialog();
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
// 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니
// MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고
// 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다.
// ShowDialog <b>전에</b> 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서
// 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다).
if (designer.Pages.Count > 0)
{
var first = designer.Pages[0];
dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize(
first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다
}
if (dialog.ShowDialog() != true)
{
return;
}
var document = BuildFixedDocument(designer);
var document = BuildFixedDocument(designer, tags, fields, checks);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
}
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
/// <summary>
/// 페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용).
///
/// <b>해석기를 반드시 받아 넘긴다.</b> 전에는 인자 없이 BuildPageVisual(page) 을 불러서
/// 태그·데이터소스 해석기가 <b>인쇄 경로에서만 사라졌다</b> — 미리보기에는 환자 값이
/// 찍히는데 그 창의 '인쇄' 버튼으로 뽑은 종이는 태그 이름 그대로였다.
/// 화면에서 확인하고 인쇄하면 다른 종이가 나오는 것 — 미리보기의 존재 이유를 무너뜨리는 결함이었다.
/// 실제 인쇄 PDF 와 미리보기 화면을 나란히 놓고서야 발견했다.
/// </summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var document = new FixedDocument();
foreach (var page in designer.Pages)
@@ -40,7 +66,9 @@ public static class PrintService
Height = page.HeightDip,
Background = page.PaperBrush,
};
fixedPage.Children.Add(BuildPageVisual(page));
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -49,8 +77,19 @@ public static class PrintService
return document;
}
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
public static UIElement BuildPageVisual(PageViewModel page)
/// <summary>
/// 페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용).
///
/// <paramref name="printFilter"/> 가 false 면 화면에 보이는 것을 그대로 그린다.
/// <b>레거시가 그렇게 동작한다</b> — 레거시 미리보기는 <c>PrintOutPut=False</c> 컨트롤을
/// 그대로 보여 주고(그 속성은 <c>PrintMe</c> 안에만 있다) 레거시 인쇄만 뺀다.
/// 두 눈이 필요해서 미리보기 창이 보기를 전환할 수 있게 열어 둔다.
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
/// </summary>
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
var canvas = new Canvas
{
@@ -66,14 +105,23 @@ public static class PrintService
foreach (var control in page.Controls)
{
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
if (control.Model.Hidden || control is DataTableViewModel)
// 인쇄/미리보기에서 빠지는 것 셋:
// ① 디자이너 임시 숨김(Hidden — 저장되지 않는 편집 전용 상태)
// ② 데이터소스(MDataTable — 런타임 비가시)
// ③ 레거시 런타임이 인쇄에서 빼는 것: Visible=False 또는 PrintOutPut=False.
// ③ 을 오래 빠뜨려서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
// 저장 전 검증 수단이 거짓말을 하고 있었다.
// 캔버스는 계속 다 보여 준다(편집 중인 것을 못 보면 고칠 수 없다) — 여기만 거른다.
// Hidden(디자이너 임시 숨김)과 MDataTable(런타임 비가시)은 보기와 무관하게 항상 뺀다 —
// 레거시 미리보기에서도 안 보이는 것들이다. 보기가 가르는 것은 ③ 뿐이다.
if (control.Model.Hidden || control is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(control.Model)))
{
continue;
}
var presenter = new ContentPresenter
{
Content = control,
Content = PrintProjection(control, printFilter, tags, fields, checks),
Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height),
};
@@ -83,5 +131,171 @@ public static class PrintService
}
return canvas;
}
/// <summary>
/// 컨테이너를 <b>인쇄용으로 다시 만든 것</b>으로 바꿔 준다. 컨테이너가 아니면 원본 그대로.
///
/// <b>왜 필요한가.</b> 위 루프는 최상위만 훑는데 Panel·GroupBox 템플릿이 자식을 스스로 그린다
/// (<c>ItemsSource="{Binding Children}"</c>). 그래서 여기서 아무리 걸러도 컨테이너 안은 그대로 나왔다 —
/// Panel 안 컨트롤은 '인쇄 출력'을 꺼도 인쇄됐고, 컨테이너 안 MDataTable 은
/// 파란 DB 배지가 종이에 찍혔다.
///
/// <b>왜 플래그가 아닌가.</b> 처음에는 컨테이너 VM 에 '인쇄 필터' 플래그를 켰다 끄는 방법을 썼는데
/// 듣지 않았다 — WPF 는 템플릿 자식을 <b>레이아웃 시점</b>에 만드는데,
/// 이 함수는 프레젠터만 만들어 두고 반환하므로 자식이 실체화될 때는 이미 플래그가 꺼져 있다.
/// 플래그 수명을 인쇄 비주얼 전체로 늘리면 그동안 캔버스에서도 자식이 사라진다(VM 을 공유하므로).
///
/// 그래서 <b>같은 모델로 같은 타입의 VM 을 하나 더 만들고</b> 인쇄될 자식만 담는다.
/// 타입이 같으니 템플릿이 그대로 잡히고, 모델이 같으니 배경·테두리·머리글이 전부 똑같이 나온다.
/// 자식 VM 은 새로 만들지 않고 원본을 그대로 담는다 — 해석된 글꼴·색이 유지된다.
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
/// </summary>
private static object PrintProjection(ControlViewModel control, bool printFilter,
SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
if (control is not ContainerViewModel container)
{
return WithTagValue(control, tags, fields, checks);
}
ContainerViewModel? copy = container switch
{
PanelViewModel => new PanelViewModel(container.Model),
GroupBoxViewModel => new GroupBoxViewModel(container.Model),
// 모르는 컨테이너 타입은 손대지 않는다 — 잘못 투영하느니 원본을 그리는 쪽이 낫다
_ => null,
};
if (copy is null)
{
return control;
}
foreach (var child in container.Children)
{
if (child.Model.Hidden || child is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(child.Model)))
{
continue;
}
if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
{
copy.Children.Add(projected);
}
}
// 사본은 시각 컨텍스트를 아직 못 받았다 — 원본이 해석해 둔 값을 부모 값으로 넘긴다.
// 컨테이너에 자기 글꼴 속성이 있으면 그것이 이기고, 없으면 상속값이 그대로 온다 — 양쪽 다 원본과 같다.
copy.ResolveVisualContext(container.EffectiveFont, container.Foreground);
return copy;
}
/// <summary>
/// 데이터 태그가 붙은 컨트롤을 <b>값이 들어간 사본</b>으로 바꾼다. 태그가 없거나 해석기가 없으면 원본.
///
/// <b>왜 필요한가.</b> 지금 미리보기는 태그를 글자 그대로 찍는다 —
/// <c>PAT_이름</c> 이 종이에 그렇게 인쇄된다. 값이 흐를 관이 아예 없었다.
///
/// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다.
/// 타입이 같으니 템플릿이 그대로 잡히고, <b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b>
///
/// <b>못 만든 값은 빈칸이다</b>(2026-08-20 사용자 결정). 처음에는 사유를 대괄호로
/// 감싸 그렸는데("못 만든 것을 감추지 않는다"), 실사용 출력물에 [태그 — 사유] 가
/// 찍히면 안 되므로 값이 안 되는 태그는 종이·미리보기 모두 빈칸으로 바꾼다 —
/// 태그 이름을 그대로 두면 값이 나온 줄 알기 때문에 이름도 지운다.
/// 사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
/// </summary>
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{
// 미리보기에서 눌러 본 체크·라디오는 <b>무엇보다 뒤다</b> — 태그가 만든 체크 상태까지 덮는다.
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
bool? clicked = null;
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
{
clicked = state;
}
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields);
if (label is null)
{
return clicked is null ? control : Rechecked(control, clicked.Value);
}
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
return clicked is null ? control : Rechecked(control, clicked.Value);
}
// 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
// 못 찾았으면 아무것도 넣지 않는다 — 자리표시자만 남고 종이에는 글자가 없다.
if (control is PictureBoxViewModel)
{
if (!value.Resolved)
{
return control;
}
var picture = control.Model.Clone();
picture.Props.SetText("ResolvedImagePath", value.Text);
return DocumentMapper.CreateControl(picture, control.EffectiveFont, control.Foreground);
}
var model = control.Model.Clone();
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
// 체크류는 레거시 SetValue 의미론이다 — 값이 "Y" 또는 "TRUE"(대소문자 무시)면 체크,
// 그 외는 언체크(CheckBox.vb:688-696 · RadioButton.vb:338-351). 라벨(Text)은 건드리지
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
var upper = value.Text.ToUpperInvariant();
model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False");
}
else
{
// 값을 못 만들면 빈칸 — 사유를 종이에 쓰지 않는다(위 요약 참조)
model.Props.SetText("Text", value.Resolved ? value.Text : string.Empty);
}
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
/// <summary>미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다</summary>
private static object Rechecked(ControlViewModel control, bool state)
{
var model = control.Model.Clone();
model.Props.SetText("Checked", state ? "True" : "False");
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
}
/// <summary>
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
///
/// <c>DataTableField</c> 는 형식을 어기면 레거시 런타임이 <b>빈 catch 로 삼켜</b>
/// 아무 표시 없이 빈칸이 된다(ucLoadSheetBase.DataTableBinding_). 여기서는 파싱 실패도 말한다 —
/// 디자이너에서 정상으로 보이고 임상 화면에서만 빈칸이 되는 것이 이 배선의 고질적 고장이다.
/// </summary>
private static (string? Label, SheetMe.Core.Catalog.TagValue Value) Bound(
ControlViewModel control,
SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields)
{
var tag = control.Model.Props.GetText("DataInterfaceTag");
if (tags is not null && tag is { Length: > 0 } && tag != "None")
{
return (tag, tags.Resolve(tag));
}
var wiring = control.Model.Props.GetText("DataTableField");
if (fields is null || wiring is not { Length: > 0 })
{
return (null, SheetMe.Core.Catalog.TagValue.None);
}
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring);
return spec is null
? (wiring, new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다"))
: ($"{spec.TableName}.{spec.Field}", fields.Resolve(spec));
}
#endregion
}
@@ -0,0 +1,70 @@
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
namespace SheetMe.Designer.Services;
/// <summary>
/// 지금 세션만으로 만들 수 있는 태그 값 — 서버 시각 계열 11종, 로그인 사용자 계열 5종.
///
/// <b>이것이 오늘 가능한 전부다.</b> 데이터 태그 384종 중 값이 나오는 것은 이 16종(운영 사용 579건, 9.1%)뿐이고
/// 나머지는 환자·내원 문맥이 있어야 한다. 그 문맥이 생기면 이 클래스를 대체하는 것이 아니라
/// <b>앞에 하나 더 붙인다</b> — 시각·사용자 태그는 그때도 여기서 나온다.
///
/// <b>DB 는 창당 한 번만 읽는다.</b> 재료는 서버 시각 1회 + 사용자 1행 1회로 끝난다
/// (<see cref="TagPreviewStore"/>). 태그마다 읽으면 태그 40개짜리 서식에 왕복 40회가 된다 —
/// 레거시가 그렇게 하고 있고, 그것을 그대로 옮기지 않기로 한 이유다.
/// </summary>
public sealed class SessionTagResolver : ITagValueResolver
{
#region Member Fields
private readonly TagPreviewStore? store;
private TagPreviewContext? context;
private bool tried;
#endregion
#region Constructors
public SessionTagResolver()
{
var connection = ConfigService.Current.ConnectionString;
store = connection.Length > 0 ? new TagPreviewStore(connection) : null;
}
#endregion
#region Methods
public TagValue Resolve(string tag)
{
if (tag.Length == 0)
{
return TagValue.None;
}
if (!TagPreviewCatalog.CanResolve(tag))
{
// 사유를 돌려준다 — 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다
return new TagValue(false, TagPreviewCatalog.ReasonText(tag));
}
if (store is null || !store.CanUseDb)
{
return new TagValue(false, "DB 에 접속되어 있지 않습니다");
}
if (!tried)
{
tried = true;
context = store.Read(UserSession.Current.UidCod, UserSession.Current.UidNam);
}
if (context is not { } ready)
{
return new TagValue(false, "값을 읽지 못했습니다");
}
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
? TagPreviewCatalog.FromClock(tag, ready.Minute12)
: TagPreviewCatalog.FromUser(tag, ready.UidCod, ready.UidNam,
ready.MobilePhone, ready.OfficePhone,
UserSession.Current.LicenseNumber, UserSession.Current.SpecialistNumber,
UserSession.Current.JobName, UserSession.Current.DepNam,
UserSession.Current.DepPhone);
return value.Length > 0
? new TagValue(true, value)
: new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다");
}
#endregion
}
@@ -16,6 +16,14 @@ public static class StartupArguments
{
#region Methods
/// <summary>진단 플래그 기동인지 — 첫 인자가 "--" 로 시작</summary>
/// <summary>
/// 이번 실행이 진단 모드인가. App 이 기동 때 한 번 정하고, 화면 쪽에서 읽는다.
///
/// 화면이 이것을 봐야 하는 이유: 게이트가 MainView 를 띄울 때 Loaded 커맨드가 DB 를 치면
/// 검사 결과가 접속 상태에 따라 달라진다. 게이트는 DB 없이 같은 답을 내야 한다.
/// </summary>
public static bool DiagnosticMode { get; set; }
public static bool IsDiagnostic(string[] args)
=> args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal);
+37 -1
View File
@@ -19,6 +19,9 @@ public sealed class UndoService
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
private int savedDepth;
/// <summary>마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다</summary>
private bool lastDirty;
#endregion
#region Properties
@@ -47,6 +50,12 @@ public sealed class UndoService
#region Events
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
public event Action? Restored;
/// <summary>
/// <see cref="IsDirty"/> 값이 <b>바뀌었을 때만</b> 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다.
/// 매번 통지하면 드래그 한 번에 수백 번 울린다.
/// </summary>
public event Action? DirtyChanged;
#endregion
#region Methods
@@ -72,10 +81,15 @@ public sealed class UndoService
}
redoStack.Clear();
lastNudgeAt = DateTime.MinValue;
RaiseDirtyIfChanged();
}
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
public void MarkSaved() => savedDepth = undoStack.Count;
public void MarkSaved()
{
savedDepth = undoStack.Count;
RaiseDirtyIfChanged();
}
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge()
@@ -100,6 +114,27 @@ public sealed class UndoService
undoStack.RemoveAt(undoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
RaiseDirtyIfChanged();
}
/// <summary>
/// 다시 실행 이력을 버린다 — <b>취소</b>한 조작이 Ctrl+Y 로 되살아나면 안 될 때.
///
/// Esc 로 드래그를 취소하는 경우가 그렇다. 사용자는 "되돌린다"가 아니라 "없던 일로 한다"고
/// 생각하는데, 취소가 Undo 로 구현되어 있으면 그 조작이 redo 이력에 남아 되살아난다.
/// </summary>
public void DiscardRedo() => redoStack.Clear();
/// <summary>더티 값이 실제로 바뀐 경우에만 통지한다</summary>
private void RaiseDirtyIfChanged()
{
var now = IsDirty;
if (now == lastDirty)
{
return;
}
lastDirty = now;
DirtyChanged?.Invoke();
}
/// <summary>다시 실행</summary>
@@ -114,6 +149,7 @@ public sealed class UndoService
redoStack.RemoveAt(redoStack.Count - 1);
restoreDocument(snapshot);
Restored?.Invoke();
RaiseDirtyIfChanged();
}
#endregion
}
+50 -6
View File
@@ -16,7 +16,14 @@ public static class UserPrefs
private static readonly string SettingsPath =
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "prefs.json");
private static Dictionary<string, bool> values = new(StringComparer.Ordinal);
/// <summary>
/// 값 형식이 <see cref="JsonElement"/> 인 이유: 전에는 <c>Dictionary&lt;string, bool&gt;</c> 였고
/// 그래서 숫자를 담을 수 없었다(창 크기·패널 폭을 저장하지 못한 원인이다).
/// 여기서 형식을 <c>string</c> 이나 <c>double</c> 로 바꾸면 이미 깔린 prefs.json 이
/// 역직렬화에서 터져 <b>기존 취향이 통째로 초기화된다</b>.
/// JsonElement 는 <c>false</c> 와 <c>640</c> 을 모두 그대로 받으므로 옛 파일이 살아남는다.
/// </summary>
private static Dictionary<string, JsonElement> values = new(StringComparer.Ordinal);
private static bool loaded;
#endregion
@@ -25,21 +32,58 @@ public static class UserPrefs
public static bool GetBool(string key, bool fallback)
{
EnsureLoaded();
return values.TryGetValue(key, out var value) ? value : fallback;
return values.TryGetValue(key, out var value)
&& value.ValueKind is JsonValueKind.True or JsonValueKind.False
? value.GetBoolean()
: fallback;
}
/// <summary>불리언 취향 저장</summary>
public static void SetBool(string key, bool value)
{
EnsureLoaded();
if (values.TryGetValue(key, out var current) && current == value)
if (GetBool(key, !value) == value && values.ContainsKey(key))
{
return;
}
values[key] = value;
values[key] = JsonSerializer.SerializeToElement(value);
Save();
}
/// <summary>숫자 취향 읽기 — 미저장이거나 형식이 다르면 기본값</summary>
public static double GetDouble(string key, double fallback)
{
EnsureLoaded();
return values.TryGetValue(key, out var value)
&& value.ValueKind == JsonValueKind.Number
&& value.TryGetDouble(out var number)
&& double.IsFinite(number)
? number
: fallback;
}
/// <summary>
/// 숫자 취향 여러 개를 한 번에 저장 — 창을 닫을 때 6개를 각각 저장하면 파일을 6번 쓴다.
/// </summary>
public static void SetDoubles(params (string Key, double Value)[] entries)
{
EnsureLoaded();
var changed = false;
foreach (var (key, value) in entries)
{
if (!double.IsFinite(value) || Math.Abs(GetDouble(key, double.NaN) - value) < 0.5)
{
continue;
}
values[key] = JsonSerializer.SerializeToElement(Math.Round(value, 1));
changed = true;
}
if (changed)
{
Save();
}
}
private static void EnsureLoaded()
{
if (loaded)
@@ -51,8 +95,8 @@ public static class UserPrefs
{
if (File.Exists(SettingsPath))
{
values = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(SettingsPath))
?? new Dictionary<string, bool>(StringComparer.Ordinal);
values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(File.ReadAllText(SettingsPath))
?? new Dictionary<string, JsonElement>(StringComparer.Ordinal);
}
}
catch
@@ -0,0 +1,99 @@
using System.Windows;
namespace SheetMe.Designer.Services;
/// <summary>
/// 창 크기·위치·패널 폭을 실행 사이에 기억한다.
///
/// <b>왜 필요한가.</b> 전에는 아무것도 기억하지 않아서, 창을 옮기거나 패널 폭을 조절해도
/// 다음 실행에 원래대로 돌아갔다. 하루 종일 쓰는 사람에게 그 조작은 매일 반복하는 일이 된다.
///
/// <b>복원값을 반드시 클램프한다.</b> 저장된 사각형이 지금 화면에 없을 수 있다 —
/// 회사에서 두 번째 모니터를 쓰다 집에서 노트북만 켜면, 저장된 위치가 존재하지 않는 화면을 가리킨다.
/// 이 앱은 <c>WindowStyle="None"</c> 이라 OS 캡션이 없고 제목 표시줄을 직접 그리므로,
/// 창이 화면 위쪽으로 나가면 <b>메뉴·저장·닫기가 전부 손에 닿지 않는다</b>(마우스로 되돌릴 방법이 없다).
/// 그래서 복원 전에 작업영역과 교차 검사를 한다.
///
/// 현장 단말이 1920x1080 이상이라 기본 크기(1440x920)로는 이 문제가 나지 않는다.
/// 그래도 클램프를 두는 이유는, 저장을 시작하는 순간 <b>사용자가 만든 값</b>이 복원되기 때문이다 —
/// 기본값이 안전한 것과 저장된 값이 안전한 것은 다른 문제다.
/// </summary>
public static class WindowPlacement
{
#region Member Fields
private const string KeyLeft = "Window.Left";
private const string KeyTop = "Window.Top";
private const string KeyWidth = "Window.Width";
private const string KeyHeight = "Window.Height";
private const string KeyMaximized = "Window.Maximized";
private const string KeyLeftPanel = "Panel.Left.Width";
private const string KeyRightPanel = "Panel.Right.Width";
/// <summary>제목 표시줄을 잡을 수 있어야 하는 최소 노출 폭·높이</summary>
private const double Grip = 120;
#endregion
#region Methods
/// <summary>
/// 저장된 사각형을 복원한다 — 화면 밖이면 버리고 기본값을 쓴다.
/// <c>Show()</c> 전에 불러야 한다(보인 뒤에 옮기면 한 프레임 번쩍인다).
/// </summary>
public static void Restore(Window window)
{
var width = UserPrefs.GetDouble(KeyWidth, window.Width);
var height = UserPrefs.GetDouble(KeyHeight, window.Height);
var left = UserPrefs.GetDouble(KeyLeft, double.NaN);
var top = UserPrefs.GetDouble(KeyTop, double.NaN);
window.Width = Math.Max(window.MinWidth, width);
window.Height = Math.Max(window.MinHeight, height);
if (double.IsNaN(left) || double.IsNaN(top))
{
return;
}
var work = SystemParameters.WorkArea;
var rect = new Rect(left, top, window.Width, window.Height);
// 어느 화면에도 충분히 걸치지 않으면 저장값을 버린다. 여러 모니터를 정확히 다루려면
// 모니터 열거가 필요하지만, 주 화면 작업영역과의 교차만으로도 '손에 닿지 않는 창'은 막힌다.
if (rect.Right < work.Left + Grip || rect.Left > work.Right - Grip
|| rect.Bottom < work.Top + Grip || rect.Top > work.Bottom - Grip)
{
return;
}
window.WindowStartupLocation = WindowStartupLocation.Manual;
window.Left = left;
// 위로는 절대 넘기지 않는다 — 제목 표시줄이 화면 밖으로 나가면 되돌릴 수 없다
window.Top = Math.Max(work.Top, top);
if (UserPrefs.GetBool(KeyMaximized, false))
{
window.WindowState = WindowState.Maximized;
}
}
/// <summary>지금 상태를 저장한다 — 창을 닫을 때 한 번 부른다</summary>
public static void Save(Window window, double leftPanel, double rightPanel)
{
UserPrefs.SetBool(KeyMaximized, window.WindowState == WindowState.Maximized);
// 최대화 중이면 Left/Top/Width/Height 는 '복원했을 때의 값'이라 그대로 쓰는 것이 맞다.
// 최소화 중이면 그 값이 쓰레기이므로 건드리지 않는다.
if (window.WindowState == WindowState.Minimized)
{
return;
}
var bounds = window.WindowState == WindowState.Maximized
? window.RestoreBounds
: new Rect(window.Left, window.Top, window.ActualWidth, window.ActualHeight);
UserPrefs.SetDoubles(
(KeyLeft, bounds.Left), (KeyTop, bounds.Top),
(KeyWidth, bounds.Width), (KeyHeight, bounds.Height),
(KeyLeftPanel, leftPanel), (KeyRightPanel, rightPanel));
}
/// <summary>패널 폭 복원 — 0 이면 XAML 기본값을 그대로 둔다는 뜻</summary>
public static double LeftPanelWidth(double fallback) => UserPrefs.GetDouble(KeyLeftPanel, fallback);
public static double RightPanelWidth(double fallback) => UserPrefs.GetDouble(KeyRightPanel, fallback);
#endregion
}
+124 -11
View File
@@ -28,6 +28,18 @@
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<!--
화면 글자는 Display 로 그린다. WPF 기본값인 Ideal 은 글리프를 픽셀 격자에 맞추지 않고
소수 위치에 놓아서, 11~14px UI 글자가 눌린 듯 흐려 보인다.
<b>ClearType 과는 다른 축</b>이다 — Ideal 이든 Display 든 색 번짐은 그대로 생기므로
색 번짐을 세는 진단으로는 이 흐림을 잡을 수 없었다(그래서 두 번 놓쳤다).
상속 속성이라 여기 한 번 걸면 TextBlock·TextBox·버튼·표 글자에 모두 닿는다.
<b>종이는 예외다.</b> PageView 의 용지 Grid 와 PrintService 의 캔버스가 각각
Ideal 을 명시하고 그 안의 글자는 그것을 상속한다 — 인쇄 지면 자릿수가 바뀌면
레거시와 다른 종이가 된다(진단 db-render 의 md5 가 그것을 잠그고 있다).
-->
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
</Style>
<!-- 순수 Window 인스턴스용(파생 클래스에는 위 키 스타일을 직접 걸어야 한다) -->
@@ -37,7 +49,12 @@
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<!--
TextFormattingMode 을 여기서 정하지 않는다. <b>스타일 세터는 상속값을 이긴다</b> —
여기에 값을 박으면 종이 안의 글자까지 그 값이 되어 용지 Grid 가 정한 Ideal 을 덮는다.
전에 Ideal 이 박혀 있어서 화면 UI 글자가 전부 흐렸다.
지금은 창(Display)과 종이(Ideal)가 각각 상속으로 정한다.
-->
</Style>
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
@@ -55,7 +72,10 @@
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -98,7 +118,10 @@
<ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -115,8 +138,16 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.AccentFillPressed}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" />
</Trigger>
<!--
비활성은 <b>색을 빼서</b> 알린다. 전에는 불투명도만 0.45 로 내렸는데,
그러면 강조색이 옅어질 뿐 여전히 파랗고 그 위의 흰 글자가 배경에 묻힌다
(환자 선택 창의 '선택' 버튼이 그 상태로 뜬다 — 처음부터 비활성이다).
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"를 한꺼번에 없앤다.
-->
<Trigger Property="IsEnabled" Value="False">
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Input}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
@@ -557,7 +588,17 @@
<Border.Resources>
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
</Border.Resources>
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
<!-- 안쪽에 불투명 배경을 한 겹 더 두고 여기에 ClearTypeHint 를 건다.
Effect 와 AllowsTransparency 는 각각 중간 서피스를 만들어 ClearType 을 끄고,
그러면 13px 한글이 회색 안티에일리어싱으로 뭉개진다.
힌트를 Effect 가 걸린 바깥 Border 에 걸면 듣지 않는다.
중간 서피스 안쪽의 불투명 배경에 걸어야 한다(진단 cleartype 으로 잰 결과). -->
<ScrollViewer MaxHeight="260">
<Grid Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<ItemsPresenter />
</Grid>
</ScrollViewer>
</Border>
</Popup>
</Grid>
@@ -637,11 +678,12 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
<!--
탭 흐름만 둔다. 전에는 옆에 '새 서식 탭(+)' 버튼이 있었으나
서식은 반드시 목록에서 골라 연다 — 코드 없는 빈 문서는
DB 에 저장할 수 없어서, 만들 수 있게 두면 그린 뒤에 막힌다.
-->
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
<Button Content="" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
</StackPanel>
</ScrollViewer>
</Border>
@@ -711,7 +753,10 @@
<ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
<ContentPresenter.Resources>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -1096,8 +1141,12 @@
</Border>
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
Focusable="False" PopupAnimation="Fade">
<!-- AllowsTransparency 팝업은 레이어드 창이라 ClearType 이 꺼진다.
불투명 배경이 있는 이 자리에 힌트를 걸어 되살린다 —
메뉴에 배치·정렬 명령이 다 들어 있어 가장 많이 읽는 글자다. -->
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
CornerRadius="6" Padding="4" Margin="0,3,0,0">
CornerRadius="6" Padding="4" Margin="0,3,0,0"
RenderOptions.ClearTypeHint="Enabled">
<StackPanel IsItemsHost="True" />
</Border>
</Popup>
@@ -1372,4 +1421,68 @@
</Style.Triggers>
</Style>
<!-- ============================================================
대화상자 크롬 — 앱 셸·알림창과 같은 타이틀바를 <b>모든</b> 창에 준다.
<b>왜 필요했나.</b> 커스텀 크롬을 가진 창은 메인 셸과 MessageDialogView 둘뿐이었고,
나머지 열한 창(서식 이력·데이터소스 배선·글꼴 관리자·태그 선택·쿼리 편집기·환자 선택 …)은
윈도우 <b>기본</b> 타이틀바를 달고 떴다. 다크 테마에서 어두운 앱 위에 OS 바가 얹히고,
모서리·높이·글자 크기·닫기 버튼 모양이 전부 달라 "다른 프로그램이 열렸다"로 보였다.
<b>왜 스타일 하나로 하나.</b> 열한 창은 이미 전부 ThemedWindow 를 걸고 있었다 —
템플릿을 여기 한 번 두면 각 창은 스타일 이름 한 줄만 바꾸면 된다.
창마다 타이틀바 markup 을 복사하면 다음에 또 갈라진다.
AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져
13px 한글이 뭉개지고 진단이 찍는 PNG 배경이 알파가 된다.
닫기 버튼의 명령 배선과 최대화 보정은 ctl:DialogChrome 이 붙여 준다.
============================================================ -->
<Style x:Key="ThemedDialogWindow" TargetType="Window" BasedOn="{StaticResource ThemedWindow}">
<Setter Property="WindowStyle" Value="None" />
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
<!--
WindowChrome 은 여기서 못 준다. Freezable 인 데다 창에 붙는 순간 스스로 속성을 고치는데,
Style 의 Setter.Value 는 <b>한 번 평가돼 공유</b>되므로 두 번째 창부터
"읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다(x:Shared="False" 로도 안 된다 —
공유되는 것은 리소스가 아니라 Setter 가 이미 붙든 값이다).
그래서 ctl:DialogChrome 이 <b>창마다 새 인스턴스</b>를 만들어 붙인다.
-->
<Setter Property="ctl:DialogChrome.Enabled" Value="True" />
<Setter Property="Template">
<Setter.Value>
<ControlTemplate TargetType="Window">
<!-- PART_Root — 진단(dialog-shots)이 창 전체를 찍을 때 잡는 이름.
이 이름이 없으면 Content 만 찍혀 타이틀바가 사진에서 빠진다. -->
<Border x:Name="PART_Root"
BorderBrush="{DynamicResource B.Line2}" BorderThickness="1"
Background="{DynamicResource B.Surface}">
<DockPanel>
<!-- 타이틀바 — 드래그는 WindowChrome 의 CaptionHeight 가 처리한다 -->
<Border DockPanel.Dock="Top" Height="36" Background="{DynamicResource B.Titlebar}"
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<Grid>
<TextBlock Text="{TemplateBinding Title}" Margin="14,0,52,0"
VerticalAlignment="Center" FontSize="12"
Foreground="{DynamicResource B.Muted}"
TextTrimming="CharacterEllipsis" />
<!-- IsHitTestVisibleInChrome 필수 — 빠지면 클릭이 창 드래그로 먹혀 닫을 수 없다 -->
<Button Style="{StaticResource CloseBtn}" Height="36"
HorizontalAlignment="Right" IsTabStop="False" ToolTip="닫기"
Command="{x:Static SystemCommands.CloseWindowCommand}"
shell:WindowChrome.IsHitTestVisibleInChrome="True">
<TextBlock Text="&#xE8BB;" FontFamily="Segoe MDL2 Assets" FontSize="10" />
</Button>
</Grid>
</Border>
<!-- AdornerDecorator 가 있어야 검증 오류 표시·툴팁 어도너가 정상 동작한다 -->
<AdornerDecorator>
<ContentPresenter />
</AdornerDecorator>
</DockPanel>
</Border>
</ControlTemplate>
</Setter.Value>
</Setter>
</Style>
</ResourceDictionary>
@@ -199,6 +199,33 @@ public abstract class ControlViewModel : ViewModelBase, ILayerRow
/// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary>
public bool HasLayerId => LayerId.Length > 0;
/// <summary>
/// 레이어 행 둘째 줄 — <b>항상</b> 있다. 그래야 행 높이가 일정하다.
///
/// 전에는 표시명이 곧 Id 인 행(문구가 없는 컨트롤)에서 둘째 줄이 통째로 빠져
/// 목록에 1줄과 2줄이 섞였다. 훑을 때 리듬이 깨지고, 어느 행이 무엇인지
/// 눈이 매번 다시 자리를 잡아야 했다.
///
/// <b>타입명을 앞에 둔다.</b> 아이콘만으로는 타입이 안 갈리는 짝이 실제로 있다 —
/// 라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
/// 이름(Id)은 표시명과 다를 때만 뒤에 붙인다 — 같으면 윗줄과 같은 글자를 두 번 쓰는 셈이다.
/// 이름을 계속 보여 주는 이유는 그것이 <b>외부 참조 키</b>이기 때문이다(수식·액션 대상·이벤트 규약).
/// </summary>
public string LayerSubText
{
get
{
var typeName = SheetMe.Core.Catalog.ControlRegistry.Find(Type)?.DisplayName ?? Type;
// 이름이 곧 표시명이면(문구 없는 컨트롤) 타입만 — 같은 글자를 두 번 쓰지 않는다
if (!HasLayerId)
{
return typeName;
}
// 갓 놓은 컨트롤은 문구가 타입명 그대로다("라디오버튼") — 윗줄이 이미 타입을 말했으므로 이름만
return LayerName == typeName ? LayerId : $"{typeName} · {LayerId}";
}
}
/// <summary>레이어 잠금 토글 아이콘명</summary>
public string LockIcon => Model.Locked ? "lock" : "unlock";
@@ -136,6 +136,30 @@ public abstract class ContainerViewModel : ControlViewModel
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
public override bool HasLayerChildren => Children.Count > 0;
/// <summary>
/// 레이어 목록에 붙는 자식 수 — 접혀 있어도 <b>무엇이 딸려 있는지</b> 알 수 있어야 한다.
///
/// 페이지 행에는 (7), 그룹 폴더에는 (3)이 이미 붙는데 컨테이너만 없었다.
/// 그래서 접힌 패널이 3개를 품었는지 300개를 품었는지 펴 보기 전에는 알 수 없었고,
/// 그 패널을 지우거나 옮기기 전에 무엇이 함께 가는지도 알 수 없었다.
/// 중첩까지 세어 실제로 딸려 가는 수를 말한다.
/// </summary>
public string LayerChildCountText => $"({CountAll(Children)})";
private static int CountAll(IEnumerable<ControlViewModel> items)
{
var total = 0;
foreach (var item in items)
{
total++;
if (item is ContainerViewModel container)
{
total += CountAll(container.Children);
}
}
return total;
}
protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
@@ -180,6 +204,59 @@ public sealed class PictureBoxViewModel : ControlViewModel
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
public string Hint => Prop("DataInterfaceTag") ?? "이미지";
/// <summary>
/// 그릴 이미지 — 해석기가 정한 파일 경로(<c>ResolvedImagePath</c>)를 읽는다.
/// 캔버스(편집 화면)에는 그 속성이 없어 null 이고 자리표시자가 남는다.
///
/// <b>파일을 잠그지 않는다</b>(OnLoad + 스트림 복사). 레거시도 같은 이유로
/// Image.FromFile 대신 바이트를 읽어 메모리에서 디코딩한다(MPictureBox.vb:429-572) —
/// 잠그면 그 파일을 쓰는 다른 프로그램이 막힌다.
/// 못 읽으면 null 이다(깨진 파일·권한) — 레거시도 빈 catch 로 삼켜 빈칸이 됐다.
/// </summary>
public System.Windows.Media.ImageSource? Image
{
get
{
var path = Prop("ResolvedImagePath");
if (path is not { Length: > 0 })
{
return null;
}
try
{
var image = new System.Windows.Media.Imaging.BitmapImage();
image.BeginInit();
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
image.CreateOptions = System.Windows.Media.Imaging.BitmapCreateOptions.IgnoreImageCache;
image.UriSource = new Uri(path, UriKind.Absolute);
image.EndInit();
image.Freeze();
return image;
}
catch
{
return null;
}
}
}
/// <summary>
/// 채우기 방식 — 레거시 SizeMode 대응.
///
/// <b>인쇄는 레거시가 SizeMode 를 무시하고 컨트롤 사각형에 늘려 그린다</b>
/// (MPictureBox.vb:707-783 PrintMe/DrawingControl). 종이를 레거시와 맞추는 것이
/// 이 프로그램의 기준이므로 기본을 Fill 로 두고, 비율 유지를 명시한 값만 존중한다.
/// </summary>
public System.Windows.Media.Stretch ImageStretch => Prop("SizeMode") switch
{
"Zoom" => System.Windows.Media.Stretch.Uniform,
"CenterImage" or "AutoSize" or "Normal" => System.Windows.Media.Stretch.None,
_ => System.Windows.Media.Stretch.Fill,
};
/// <summary>이미지가 없을 때만 자리표시자를 보인다</summary>
public bool HasImage => Image is not null;
public PictureBoxViewModel(ControlElement model) : base(model) { }
}
@@ -65,6 +65,12 @@ public sealed class DesignerViewModel : ViewModelBase
private const double PageGap = 28;
private const double WorldPad = 24;
private static readonly List<ControlElement> clipboard = new();
/// <summary>한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다</summary>
private const int PlacementLaneSteps = 12;
/// <summary>팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다</summary>
private int placementStep;
private double zoom = 1.0;
private FormDocument document;
private PageViewModel? selectedPage;
@@ -123,6 +129,21 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary>
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName));
/// <summary>
/// 저장하지 않은 변경이 있는가 — <b>탭에 점으로 보인다</b>.
///
/// 이 상태는 <see cref="UndoService.IsDirty"/> 가 저장 지점 깊이까지 추적해 정교하게 들고
/// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
/// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지
/// <b>확인할 방법이 없다</b> — 탭 제목이 저장 전후로 글자 하나 다르지 않다.
/// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 <b>새 버전을 만든다</b> —
/// 서식마다 쓸데없는 버전이 쌓인다.
/// </summary>
public bool IsDirty => Undo.IsDirty;
/// <summary>더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다</summary>
public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty));
/// <summary>페이지 VM 목록</summary>
public ObservableCollection<PageViewModel> Pages { get; } = new();
@@ -284,6 +305,8 @@ public sealed class DesignerViewModel : ViewModelBase
ScreenTolerance = ConfigService.Current.SnapThreshold,
};
Undo = new UndoService(() => Document, ReplaceDocument);
// 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다
Undo.DirtyChanged += NotifyDirtyChanged;
Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay;
@@ -308,6 +331,15 @@ public sealed class DesignerViewModel : ViewModelBase
UngroupCommand = new Command((sender, e) => UngroupSelection());
CutCommand = new Command((sender, e) => CutSelection());
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both"));
SelectSameCommand = new Command((object param) =>
{
if (Enum.TryParse<SameKind>(param as string, out var kind))
{
SelectSame(kind);
}
});
LockCommand = new Command((sender, e) => ToggleLockSelection());
HideCommand = new Command((sender, e) => ToggleHiddenSelection());
SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty));
CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H"));
TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode());
@@ -365,6 +397,15 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
public ICustomCommand? SizeToControlCommand { get; set; }
/// <summary>같은 행·열·종류·크기로 선택 넓히기 — 인자는 <see cref="SameKind"/> 이름</summary>
public ICustomCommand? SelectSameCommand { get; set; }
/// <summary>선택 잠그기/풀기</summary>
public ICustomCommand? LockCommand { get; set; }
/// <summary>선택 숨기기/보이기</summary>
public ICustomCommand? HideCommand { get; set; }
/// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
public ICustomCommand? SpacingCommand { get; set; }
@@ -571,11 +612,58 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay();
}
/// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary>
/// <summary>
/// 문서 교체 후 재구성 — Undo 복원 경로.
///
/// <b>선택을 되살린다.</b> 재구성의 첫 줄이 <c>Selection.Clear()</c> 라, 전에는 되돌리기 한 번마다
/// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
/// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
/// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
///
/// 기준은 Id 다 — 문서 안에서 유일하고(<c>IdGenerator.CollectUsed</c>) 문서를 갈아끼워도 같다.
/// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
/// </summary>
public void ReplaceDocument(FormDocument newDocument)
{
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
var primaryId = Selection.Primary?.Id;
document = newDocument;
RebuildFromDocument();
if (keepIds.Count == 0)
{
return;
}
var wanted = new HashSet<string>(keepIds, StringComparer.OrdinalIgnoreCase);
var found = new List<ControlViewModel>();
ControlViewModel? primary = null;
void Visit(IEnumerable<ControlViewModel> controls)
{
foreach (var vm in controls)
{
if (wanted.Contains(vm.Id))
{
found.Add(vm);
if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
{
primary = vm;
}
}
if (vm is ContainerViewModel container)
{
Visit(container.Children);
}
}
}
foreach (var page in Pages)
{
Visit(page.Controls);
}
if (found.Count > 0)
{
Selection.Set(found, primary ?? found[0]);
}
}
/// <summary>페이지 세로 오프셋 재계산</summary>
@@ -711,6 +799,37 @@ public sealed class DesignerViewModel : ViewModelBase
return HitInList(page.Controls, local);
}
/// <summary>
/// 이 좌표에 <b>무엇이든</b> 있는가 — 잠긴 것·숨긴 것까지 포함해서.
///
/// <see cref="HitTestControl"/> 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다.
/// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다
/// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 <b>편집 보호</b>지 부재가 아니다.
/// </summary>
public bool AnyControlAt(Point world)
{
var page = PageAt(world.Y);
if (page is null)
{
return false;
}
return AnyInList(page.Controls, new Point(world.X, world.Y - page.OffsetY));
}
private static bool AnyInList(IList<ControlViewModel> controls, Point local)
{
foreach (var vm in controls)
{
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
return true;
}
return false;
}
private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local)
{
for (var i = controls.Count - 1; i >= 0; i--)
@@ -745,30 +864,110 @@ public sealed class DesignerViewModel : ViewModelBase
var result = new List<ControlViewModel>();
foreach (var page in Pages)
{
foreach (var vm in page.Controls)
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
{
result.Add(vm);
}
}
CollectIntersecting(page.Controls, worldRect, result);
}
return result;
}
/// <summary>
/// 마퀴에 걸리는 것을 모은다 — <b>컨테이너 안에 그린 마퀴는 그 안의 것을 고른다</b>.
///
/// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면
/// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
/// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다.
///
/// <b>규칙은 '담김'이다.</b> 마퀴가 컨테이너 사각형 <b>안에</b> 들어가 있으면
/// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까).
/// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다.
/// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다.
/// </summary>
private void CollectIntersecting(IEnumerable<ControlViewModel> controls, Rect worldRect,
List<ControlViewModel> result)
{
foreach (var vm in controls)
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
var bounds = WorldBoundsOf(vm);
if (!worldRect.IntersectsWith(bounds))
{
continue;
}
if (vm is ContainerViewModel container && bounds.Contains(worldRect))
{
CollectIntersecting(container.Children, worldRect, result);
continue;
}
result.Add(vm);
}
}
/// <summary>
/// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 <b>컨테이너 자식은 그대로 둔다</b>.
///
/// 클릭 선택은 <see cref="GroupMatesOf"/> 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다
/// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면
/// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다.
/// </summary>
public List<ControlViewModel> MarqueeHits(Rect worldRect)
{
var hits = ControlsIntersecting(worldRect);
var result = new List<ControlViewModel>();
foreach (var vm in hits)
{
// 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다
if (vm.Parent is null && vm.Model.GroupId is not null)
{
result.AddRange(GroupMatesOf(vm));
continue;
}
result.Add(vm);
}
return result.Distinct().ToList();
}
/// <summary>오버레이(선택 박스/핸들) 재계산</summary>
public void RefreshOverlay()
{
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
RefreshLockMarks();
if (Selection.Primary is not null)
{
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage;
}
}
/// <summary>
/// 잠긴 컨트롤 표시를 다시 모은다 — 오버레이(편집 크롬) 층에만 그린다.
///
/// 종이 층에 넣으면 <c>--db-render</c> 게이트가 그것을 찍어 레거시와의 픽셀 동일성이 깨진다.
/// 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어, 클릭도 드래그도 안 되는 이유를
/// 화면에서 알 수 없었다.
/// </summary>
private void RefreshLockMarks()
{
Overlay.LockMarks.Clear();
foreach (var page in Pages)
{
foreach (var vm in ControlsOfPage(page))
{
if (!vm.Model.Locked || vm.Model.Hidden)
{
continue;
}
var world = WorldBoundsOf(vm);
Overlay.LockMarks.Add(new LockMarkInfo
{
X = world.X,
Y = world.Y,
W = Math.Max(1, world.Width),
H = Math.Max(1, world.Height),
});
}
}
}
#endregion
#region Events
@@ -787,6 +986,88 @@ public sealed class DesignerViewModel : ViewModelBase
/// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다.
/// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다.
/// </summary>
/// <summary>
/// 형제 중 다음(또는 이전) 컨트롤을 고른다 — Tab / Shift+Tab.
///
/// <b>왜 z-order 가 아니라 읽는 순서인가.</b> 계획서는 z-order 순회를 적었지만,
/// 이 도구에서 Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다.
/// 기록지는 위에서 아래·왼쪽에서 오른쪽으로 읽으므로 그 순서가 손에 맞는다.
/// z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다.
/// (EMR 런타임 입력 순서인 <see cref="ApplyTabOrder"/> 와는 다른 개념이다 — 그건 저장되는 값이다.)
///
/// 범위는 <b>형제</b>다. 컨테이너 안으로는 Enter, 밖으로는 Esc 로 오간다 —
/// 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다.
/// </summary>
public void SelectSibling(bool backward)
{
var page = Selection.ActivePage ?? SelectedPage ?? Pages.FirstOrDefault();
if (page is null)
{
return;
}
var current = Selection.Primary;
var siblings = SiblingsOf(current, page);
if (siblings.Count == 0)
{
return;
}
// 읽는 순서 — 같은 줄로 볼 만큼 가까우면(8px) X 로 가른다. 그리드가 4px 이라 그 두 칸이다.
var ordered = siblings
.OrderBy(c => Math.Round(c.Y / 8))
.ThenBy(c => c.X)
.ThenBy(c => c.Model.Id, StringComparer.Ordinal)
.ToList();
var index = current is null ? -1 : ordered.IndexOf(current);
var next = index < 0
? ordered[backward ? ^1 : 0]
: ordered[((index + (backward ? -1 : 1)) % ordered.Count + ordered.Count) % ordered.Count];
Selection.SetSingle(next);
// 눈에 보이지 않는 것을 고쳐 놓는 일이 생기지 않게 화면으로 데려온다 —
// 레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다.
RevealControl(next);
}
/// <summary>Enter — 컨테이너면 안으로 들어가고, 글자를 담는 것이면 편집을 시작한다</summary>
public bool EnterSelection()
{
if (Selection.Primary is not { } primary)
{
return false;
}
if (primary is ContainerViewModel container && container.Children.FirstOrDefault() is { } child)
{
Selection.SetSingle(child);
RevealControl(child);
return true;
}
if (IsTextEditable(primary))
{
RequestInlineEdit(primary);
return true;
}
return false;
}
/// <summary>Esc — 컨테이너 밖으로 한 겹 나간다. 나갈 곳이 없으면 false(호출부가 선택을 푼다)</summary>
public bool SelectParent()
{
if (Selection.Primary?.Parent is not { } parent)
{
return false;
}
Selection.SetSingle(parent);
RevealControl(parent);
return true;
}
/// <summary>이 컨트롤과 같은 부모를 가진 것들 — 부모가 없으면 페이지 최상위</summary>
private List<ControlViewModel> SiblingsOf(ControlViewModel? viewModel, PageViewModel page)
=> viewModel?.Parent is ContainerViewModel parent
? parent.Children.ToList()
: ControlsOfPage(page).Where(c => c.Parent is null).ToList();
public void RevealControl(ControlViewModel viewModel)
{
// 다른 페이지의 컨트롤이면 그 페이지를 활성으로 — 안 그러면 선택만 되고 화면은 안 따라간다
@@ -1153,8 +1434,18 @@ public sealed class DesignerViewModel : ViewModelBase
{
return;
}
var offset = (page.Controls.Count % 8) * 16;
AddControlAt(type, new Point(page.WidthDip / 2 + offset, page.OffsetY + page.HeightDip / 3 + offset));
// 겹침 방지 오프셋은 <b>단조 증가하는 자체 카운터</b>로 센다.
//
// 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 —
// ① % 8 이라 <b>9번째가 1번째와 정확히 같은 자리</b>에 떨어진다.
// ② Controls.Count 를 쓰므로 하나 지우고 다시 놓으면 지운 것과 같은 자리로 간다.
// 계단은 종이를 벗어나기 전에 접는다 — 접히더라도 방금 놓은 것 위로는 가지 않는다.
var step = placementStep++;
var lane = step / PlacementLaneSteps;
var offset = (step % PlacementLaneSteps) * 16;
AddControlAt(type, new Point(
page.WidthDip / 2 + offset + (lane * 12),
page.OffsetY + page.HeightDip / 3 + offset));
}
/// <summary>팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위)</summary>
@@ -1315,6 +1606,71 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows();
}
/// <summary>
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
///
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
///
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
/// </summary>
public bool DuplicateSelectionInPlace()
{
var sources = Selection.Items.ToList();
if (sources.Count == 0)
{
return false;
}
var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList());
if (pastable.Count == 0)
{
return false;
}
Undo.Snapshot();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
var copies = new List<ControlViewModel>();
foreach (var vm in sources)
{
if (!pastable.Contains(vm.Model))
{
continue;
}
var page = PageOf(vm);
if (page is null)
{
continue;
}
var clone = vm.Model.Clone();
AssignNewIds(clone, usedIds);
RemapGroupId(clone, groupRemap);
if (vm.Parent is ContainerViewModel parent)
{
// 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다
parent.Model.Children.Insert(0, clone);
var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground);
childVm.Parent = parent;
parent.Children.Add(childVm);
copies.Add(childVm);
}
else
{
page.Model.Controls.Insert(0, clone);
copies.Add(CreateAndAttach(clone, page, attachToEnd: true));
}
}
if (copies.Count == 0)
{
return false;
}
Selection.Set(copies, copies.LastOrDefault());
RebuildLayerRows();
return true;
}
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{
@@ -1490,6 +1846,156 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 선택 확장의 기준 — 같은 줄로 볼 만큼 가까운가.
///
/// 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡힌다 — 8개 중 5개를 정렬해
/// 놓고 나머지 3개가 남은 것을 못 알아채는 것이 가장 나쁜 결과다. 2px 는 이 서식의
/// 컨트롤 높이(대부분 20px 이상)보다 훨씬 작아 이웃 줄을 삼키지 않는다.
/// 격자 크기에 묶지 않는다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
/// </summary>
private const double SelectSameTolerance = 2.0;
/// <summary>선택 확장의 종류 — 우클릭 메뉴·단축키가 공유한다</summary>
public enum SameKind
{
/// <summary>같은 행(Y 가 같다)</summary>
Row,
/// <summary>같은 열(X 가 같다)</summary>
Column,
/// <summary>같은 종류(타입이 같다)</summary>
Type,
/// <summary>같은 크기(너비·높이가 같다)</summary>
Size,
}
/// <summary>
/// 주 선택과 <b>같은 줄·열·종류·크기</b>인 형제를 선택에 더한다 — 반환은 더해진 개수.
///
/// <b>왜 필요한가.</b> 정렬 6종·간격 4종·같은 크기가 이미 다 있는데, 그 명령들이 전제하는
/// "올바른 선택"을 만들 수단이 없었다. 표 서식에서 가장 흔한 편집이
/// "이 행의 입력칸 8개를 조금 높이고 아래로 붙이자"인데, 그 8개를 고르는 길이
/// Ctrl+클릭 8번뿐이다. 마퀴는 <b>교차</b> 판정이라 행 띠를 스치는 것이 전부 딸려 오고,
/// 실측(787건)으로 행 무리의 94.2%에 침입자가 있으며 그 수가 중앙값 7개·p90 41개다.
/// 즉 지금은 "드래그 → 딸려온 것 빼기"가 매번 붙는다.
///
/// 범위는 <b>같은 부모의 형제</b>다 — 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있다.
/// 순수 계산이라 종이 렌더·저장 형식에 전혀 닿지 않는다.
/// </summary>
public int SelectSame(SameKind kind)
{
var anchor = Selection.Primary ?? Selection.Items.FirstOrDefault();
var page = (anchor is null ? null : PageOf(anchor)) ?? Selection.ActivePage ?? Pages.FirstOrDefault();
if (anchor is null || page is null)
{
return 0;
}
static bool Near(double left, double right) => Math.Abs(left - right) <= SelectSameTolerance;
var matches = SiblingsOf(anchor, page).Where(candidate => kind switch
{
SameKind.Row => Near(candidate.Y, anchor.Y),
SameKind.Column => Near(candidate.X, anchor.X),
SameKind.Type => string.Equals(candidate.Type, anchor.Type, StringComparison.Ordinal),
SameKind.Size => Near(candidate.Width, anchor.Width) && Near(candidate.Height, anchor.Height),
_ => false,
}).ToList();
// 잠금·숨김은 뺀다 — 편집하려고 넓히는 선택인데 편집할 수 없는 것이 섞이면
// 정렬을 눌렀을 때 "왜 이건 안 움직이지"가 된다
matches = matches.Where(c => !c.Model.Locked && !c.Model.Hidden).ToList();
var before = Selection.Items.Count;
var merged = Selection.Items.Union(matches).Distinct().ToList();
if (merged.Count == before)
{
return 0;
}
Selection.Set(merged, anchor);
return merged.Count - before;
}
/// <summary>
/// 선택 크기를 키우거나 줄인다 — <see cref="NudgeSelection"/> 의 빠져 있던 짝.
///
/// <b>왜 필요한가.</b> 위치는 방향키로 1px 미세 조정이 되는데 크기만 그 수단이 없었다.
/// 입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력한다 —
/// 캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이
/// 화면 6px 이라 작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.
///
/// 넛지와 같은 400ms 코얼레스를 쓴다 — 연타가 Undo 스무 개로 쌓이면 안 된다.
/// </summary>
public void ResizeSelection(double dw, double dh)
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.SnapshotForNudge();
foreach (var vm in Selection.Items)
{
if (vm.Model.Locked)
{
continue;
}
vm.Width = Math.Max(1, Math.Round(vm.Width + dw));
vm.Height = Math.Max(1, Math.Round(vm.Height + dh));
// 선은 두께 축을 끌어서 바꿀 수 없다 — 레거시 MLine 이 매번 BorderWidth 로 되돌린다.
// 허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.
if (SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(vm.Model))
{
vm.NotifyAllChanged();
}
}
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 선택을 잠그거나 푼다 — 하나라도 안 잠겼으면 전부 잠근다(레이어 패널의 묶음 토글과 같은 규칙).
///
/// <b>왜 캔버스에서 되어야 하나.</b> 배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때,
/// 지금은 좌측 패널을 레이어 탭으로 바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 한다.
/// 캔버스에서 마퀴로 40개를 잡아 놔도 한 번에 잠글 수단이 없었다.
///
/// <b>잠금 해제는 캔버스에서 못 한다.</b> 히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다 —
/// 해제 경로는 레이어 패널에 있다. 그래서 캔버스 메뉴 이름은 '선택 잠그기'로 한정한다.
/// <b>잠금·숨김은 저장되지 않는 편집 전용 상태다</b> — 영속처럼 읽히는 문구를 쓰지 않는다.
/// </summary>
public void ToggleLockSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
var locking = Selection.Items.Any(vm => !vm.Model.Locked);
foreach (var vm in Selection.Items)
{
vm.Model.Locked = locking;
vm.NotifyAllChanged();
}
RebuildLayerRows();
RefreshOverlay();
}
/// <summary>선택을 숨기거나 보인다 — 하나라도 보이면 전부 숨긴다</summary>
public void ToggleHiddenSelection()
{
if (Selection.Items.Count == 0)
{
return;
}
Undo.Snapshot();
var hiding = Selection.Items.Any(vm => !vm.Model.Hidden);
foreach (var vm in Selection.Items)
{
vm.Model.Hidden = hiding;
vm.NotifyAllChanged();
}
RebuildLayerRows();
RefreshOverlay();
}
/// <summary>활성 페이지 전체 선택</summary>
public void SelectAllOnActivePage()
{
@@ -1671,8 +2177,13 @@ public sealed class DesignerViewModel : ViewModelBase
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
/// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
/// 즉 이 조작이 "문항 만들기" 다.
/// </summary>
public void GroupSelection()
{
@@ -1686,6 +2197,15 @@ public sealed class DesignerViewModel : ViewModelBase
DialogService.Notify(DialogKind.Warning, "그룹", reason);
return;
}
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
if (RadioSplitWarning(page, targets) is { } warning
&& !DialogService.Confirm("그룹", warning,
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
{
return;
}
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
@@ -1739,15 +2259,7 @@ public sealed class DesignerViewModel : ViewModelBase
/// </summary>
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{
// ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다).
// '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다.
if (targets.Any(vm => vm.Type == "RadioButton"))
{
return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n"
+ "라디오는 그룹으로 묶지 마세요.";
}
// ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
// 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
if (indices.Any(i => i < 0))
{
@@ -1762,6 +2274,32 @@ public sealed class DesignerViewModel : ViewModelBase
return null;
}
/// <summary>
/// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null.
///
/// <b>왜 되묻는가.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
/// <c>GroupName</c> 이 없고 <b>직계 부모가 곧 배타 단위</b>다(RadioButton.vb:7-8).
/// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만,
/// 같은 자리에 있던 라디오 중 <b>일부만</b> 묶으면 한 문항이 두 묶음으로 갈라져
/// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로.
/// </summary>
internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
{
var picked = targets.Count(vm => vm.Type == "RadioButton");
if (picked == 0)
{
return null;
}
// 지금은 최상위만 묶을 수 있으므로(위 targets 필터) 남는 형제도 최상위에서 센다
var siblings = page.Model.Controls.Count(c =>
string.Equals(c.Type, "RadioButton", StringComparison.Ordinal)
&& !targets.Any(vm => ReferenceEquals(vm.Model, c)));
return siblings == 0
? null
: $"이 페이지에 라디오 버튼 {picked + siblings}개 중 {picked}개만 묶습니다.\n"
+ $"남는 {siblings}개는 다른 선택 묶음이 됩니다. 계속할까요?";
}
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
/// <summary>
@@ -2122,6 +2660,19 @@ public sealed class DesignerViewModel : ViewModelBase
=> total.TryGetValue(id, out var t) && t > 0
&& selected.TryGetValue(id, out var s) && s == t;
// 조상 중에 '선택된 컨테이너'가 있는가 — 선택한 패널의 <b>범위</b>를 목록에 그리기 위한 판정
static bool HasSelectedContainerAncestor(ControlViewModel viewModel)
{
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
{
if (parent.IsSelected)
{
return true;
}
}
return false;
}
var states = new LayerHighlight[LayerRows.Count];
for (var i = 0; i < LayerRows.Count; i++)
{
@@ -2131,6 +2682,12 @@ public sealed class DesignerViewModel : ViewModelBase
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
// 선택한 컨테이너 안에 있는 것 — 그룹 판정보다 <b>뒤에</b> 둔다(그룹 색을 이기면 안 된다).
//
// 캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
// 밴드가 붙으면 "이걸 지우면 무엇이 같이 지워지나"가 선택 즉시 보인다 —
// 접었다 펴 보지 않아도 된다.
ControlViewModel c when HasSelectedContainerAncestor(c) => LayerHighlight.Navy,
_ => LayerHighlight.None,
};
}
@@ -2223,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
/// <summary>
/// 이 컨트롤의 행이 목록에 <b>나타나도록</b> 조상을 전부 편다 — 실제로 편 것이 있으면 true.
///
/// <b>왜 필요한가.</b> 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다.
/// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면
/// 목록에는 <b>행 자체가 없어</b> 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고,
/// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다.
/// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다.
///
/// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 <see cref="RevealControl"/> 로 이미 있다 —
/// 이것은 <b>그 역방향을 잇는 일</b>이지 새 기능이 아니다.
/// </summary>
public bool ExpandLayerPathTo(ControlViewModel viewModel)
{
var changed = false;
// 페이지 마디 — 공개 메서드를 쓴다(목록 재구성 요청까지 함께 도는 경로다)
if (PageOf(viewModel) is { } page && !IsPageExpanded(page))
{
SetPageExpanded(page, true);
changed = true;
}
// 그룹 폴더 — 그룹 멤버는 폴더가 접히면 행이 안 만들어진다
if (viewModel.Model.GroupId is { } groupId && !IsGroupExpanded(groupId))
{
SetGroupExpanded(groupId, true);
changed = true;
}
// 컨테이너 조상 — 자기 자신은 펴지 않는다(자기 행은 이미 있다)
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
{
if (!parent.IsLayerExpanded)
{
parent.IsLayerExpanded = true;
changed = true;
}
}
return changed;
}
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary>
@@ -2322,6 +2919,69 @@ public sealed class DesignerViewModel : ViewModelBase
/// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열).
/// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기.
/// </summary>
/// <summary>
/// 선택한 것들 사이 간격을 <b>정확한 픽셀 값으로</b> 놓는다.
///
/// <b>왜 필요한가.</b> 여기까지 간격을 조절하는 길은 4px 씩 넓게/좁게뿐이었다.
/// 20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 <b>12왕복</b>이었다.
/// 단축키(Alt+[ / Alt+])를 붙여 왕복은 없앴지만 여전히 4px 씩이다.
/// 드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver),
/// 명령 경로에는 그 값을 <b>넣을 칸</b>이 없었다.
///
/// 첫 항목은 움직이지 않는다 — 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다.
/// </summary>
public void SetExactSpacing(double gap, bool horizontal)
{
if (Selection.Items.Count < 2 || !double.IsFinite(gap) || gap < 0)
{
return;
}
var ordered = Selection.Items
.Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm)))
.OrderBy(x => horizontal ? x.World.X : x.World.Y)
.ToList();
Undo.Snapshot();
var cursor = horizontal ? ordered[0].World.Right : ordered[0].World.Bottom;
for (var i = 1; i < ordered.Count; i++)
{
var (vm, world, offset) = ordered[i];
if (horizontal)
{
vm.X = Math.Round(cursor + gap - offset.X);
cursor += gap + world.Width;
continue;
}
vm.Y = Math.Round(cursor + gap - offset.Y);
cursor += gap + world.Height;
}
Selection.NotifyBoundsChanged();
}
/// <summary>
/// 지금 선택의 간격 — 숫자 칸에 무엇을 보여 줄지.
/// 값이 서로 다르면 null(빈 칸) — 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
/// </summary>
public double? CurrentSpacing(bool horizontal)
{
if (Selection.Items.Count < 2)
{
return null;
}
var ordered = Selection.Items
.Select(vm => WorldBoundsOf(vm))
.OrderBy(r => horizontal ? r.X : r.Y)
.ToList();
var gaps = new List<double>();
for (var i = 1; i < ordered.Count; i++)
{
gaps.Add(Math.Round(horizontal
? ordered[i].X - ordered[i - 1].Right
: ordered[i].Y - ordered[i - 1].Bottom));
}
return gaps.Distinct().Count() == 1 ? gaps[0] : null;
}
public void AdjustSpacing(string mode)
{
var isHorizontal = mode.StartsWith("Horiz", StringComparison.Ordinal);
@@ -112,6 +112,46 @@ public sealed class InspectorViewModel : ViewModelBase
/// <summary>간격 균등 버튼을 보일 것인가 — 사이가 있어야 나눌 것이 있다(3개 이상)</summary>
public bool CanDistribute => designer.CanDistribute();
/// <summary>
/// 정확한 간격은 <b>둘</b>부터 뜻이 있다 — 라벨과 입력칸 한 쌍이 가장 흔한 경우다.
/// 균등 나누기(<see cref="CanDistribute"/>)가 셋을 요구하는 것과 기준이 다르다.
/// </summary>
public bool CanSpaceExactly => designer.Selection.Items.Count >= 2;
/// <summary>
/// 지금 세로 간격. 값이 갈리면 빈 칸이다 —
/// 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
/// </summary>
public string VerticalGapText
{
get => designer.CurrentSpacing(horizontal: false) is { } gap
? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
: string.Empty;
set
{
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var gap))
{
designer.SetExactSpacing(gap, horizontal: false);
}
}
}
public string HorizontalGapText
{
get => designer.CurrentSpacing(horizontal: true) is { } gap
? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
: string.Empty;
set
{
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
System.Globalization.CultureInfo.InvariantCulture, out var gap))
{
designer.SetExactSpacing(gap, horizontal: true);
}
}
}
/// <summary>
/// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
/// </summary>
@@ -274,6 +314,9 @@ public sealed class InspectorViewModel : ViewModelBase
{
OnPropertyChanged(nameof(CanAlignVertical));
OnPropertyChanged(nameof(CanDistribute));
OnPropertyChanged(nameof(CanSpaceExactly));
OnPropertyChanged(nameof(VerticalGapText));
OnPropertyChanged(nameof(HorizontalGapText));
OnPropertyChanged(nameof(AlignScopeText));
OnPropertyChanged(nameof(VerticalScopeText));
OnPropertyChanged(nameof(AlignSelectionScopeLabel));
@@ -298,8 +341,38 @@ public sealed class InspectorViewModel : ViewModelBase
}
}
/// <summary>
/// 행이 통째로 다시 만들어지려는 순간 / 다 만들어진 순간.
///
/// 뷰가 포커스와 스크롤을 되돌려 놓는 데 쓴다. 선택이 바뀌면 <see cref="Rows"/> 를 비우고
/// 새로 만들기 때문에, 그대로 두면 <b>컨트롤을 옮길 때마다 편집하던 칸과 스크롤 위치가 사라진다</b>.
/// 하루 수백 건을 다루는 사람에게 그건 "다음 것으로 넘어가면 손이 처음으로 돌아간다"는 뜻이다.
///
/// 행 컬렉션을 키 기준 갱신으로 바꾸는 쪽이 더 근본적이지만, 이 패널은 회귀 기록이 가장 많은 곳이라
/// 이번에는 손대지 않는다. 실제 아픔은 재구성 비용이 아니라 <b>포커스·스크롤 소실</b>이고
/// (--scale-budget 이 뷰모델 작업은 마이크로초 단위임을 보여 준다) 그것만 되돌려도 손이 이어진다.
/// </summary>
public event Action? Rebuilding;
public event Action? Rebuilt;
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
public void Rebuild()
{
// 감싸는 이유: 본문에 이른 return 이 여러 갈래 있어, 끝에 이벤트를 두면
// 도는 경로와 안 도는 경로가 갈린다. 그러면 어떤 선택 변경에서는 포커스가 안 돌아온다.
Rebuilding?.Invoke();
try
{
RebuildCore();
}
finally
{
Rebuilt?.Invoke();
}
}
private void RebuildCore()
{
Rows.Clear();
boundsRows.Clear();
@@ -315,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
var items = designer.Selection.Items;
if (items.Count == 0)
{
RefreshTabCounts(null);
RefreshTabCounts(Array.Empty<PropertyDef>(), 0);
if (designer.InspectedPage is { } page)
{
BuildPageRows(page);
@@ -324,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
return;
}
// 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다
var type = items[0].Type;
var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null;
RefreshTabCounts(descriptor);
// 편집할 속성 집합을 <b>먼저</b> 정하고, 그 결과로 탭을 정한다.
//
// 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
// 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다.
// 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이
// 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고
// 왜 없는지 알려 주는 표시도 없었다.
var models = items.Select(i => i.Model).ToList();
var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList();
var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null;
// MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이
// 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다.
var merged = BatchEditPolicy.MergeCommonDefs(types)
.Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed)
.ToList();
RefreshTabCounts(merged, items.Count);
if (selectedTab == InspectorTab.Design)
{
@@ -356,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
AddFontStyleSegment();
}
// 타입 전용 — 선택 탭에 해당하는 속성만
// 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
if (descriptor is not null)
if (merged.Count > 0)
{
// 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라
// 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다.
foreach (var def in descriptor.Properties)
foreach (var def in merged)
{
curatedKeys.Add(def.Key);
}
var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
if (tabDefs.Count > 0)
{
AddSection(descriptor.DisplayName);
// 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
// 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
AddSection(types.Count == 1 && descriptor is not null
? descriptor.DisplayName
: $"공통 속성 ({types.Count}종 타입)");
foreach (var def in tabDefs)
{
AddDefRow(def, descriptor.Type);
// 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다
AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models);
}
// 함께 못 바꾸는 것이 있으면 <b>말한다</b>. 조용히 빼면 그 속성이 없다고 읽는다.
var excluded = BatchEditPolicy.ExcludedCount(types);
if (excluded > 0)
{
AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음",
$"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요"));
}
}
}
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가).
// 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가).
// 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다.
if (items.Count == 1)
// 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다.
if (!items.Any(i => DesignerViewModel.IsSpread(i.Model)))
{
BuildAdvancedRows(items[0], curatedKeys);
BuildAdvancedRows(items, curatedKeys);
}
OnPropertyChanged(nameof(IsTabEmpty));
@@ -396,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
/// </summary>
private void RefreshTabCounts(ControlDescriptor? descriptor)
private void RefreshTabCounts(IReadOnlyList<PropertyDef> typed, int selectionCount)
{
var typed = descriptor?.Properties ?? (IReadOnlyList<PropertyDef>)Array.Empty<PropertyDef>();
// 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다
DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
// 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다.
// 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 —
// 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다.
DesignCount = (selectionCount == 1 ? 11 : 10)
+ typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
@@ -475,7 +575,8 @@ public sealed class InspectorViewModel : ViewModelBase
// 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
AddSection("기본 글꼴 (컨트롤이 상속)");
AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v,
isFamily: true);
AddPageFontRow(page, "크기(pt)",
f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0
@@ -573,9 +674,12 @@ public sealed class InspectorViewModel : ViewModelBase
return row;
}
/// <summary>페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다</summary>
/// <summary>
/// 페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다.
/// <paramref name="isFamily"/> 면 이름 칸이라 설치 글꼴 목록에서 고를 수 있게 한다.
/// </summary>
private void AddPageFontRow(PageViewModel page, string label,
Func<LegacyFont, string> get, Action<LegacyFont, string> set)
Func<LegacyFont, string> get, Action<LegacyFont, string> set, bool isFamily = false)
{
var root = page.Model.Root;
LegacyFont Current()
@@ -584,7 +688,8 @@ public sealed class InspectorViewModel : ViewModelBase
return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont();
}
AddPageRow(page, new TextRowViewModel(label), () => get(Current()), value =>
PropertyRowViewModel row = isFamily ? NewFontFamilyRow(label) : new TextRowViewModel(label);
AddPageRow(page, row, () => get(Current()), value =>
{
var font = Current();
set(font, value);
@@ -592,12 +697,41 @@ public sealed class InspectorViewModel : ViewModelBase
}, () => designer.RefreshPageInheritance(page));
}
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
/// <summary>
/// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가.
///
/// <b>다중 선택에서 위험한 곳이 여기다.</b> 전에는 행의 종류·편집 여부·키 목록을 전부
/// <c>items[0]</c> 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다. 그래서 이미지·중첩·참조 값을
/// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이
/// 안전해지거나 파괴적이 됐다. 게다가 <c>GetText</c> 는 문자열이 아닌 값에 null 을 돌려주므로
/// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 <b>갈렸다는 표시조차 나오지 않았다</b>.
///
/// 그래서 판정을 대상 <b>전체</b>로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다.
/// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다.
/// </summary>
private void BuildAdvancedRows(IReadOnlyList<ControlViewModel> targets, HashSet<string> curatedKeys)
{
var advancedKeys = target.Model.Props.Keys
.Where(k => !curatedKeys.Contains(k))
.ToList();
if (targets.Count == 0)
{
return;
}
var target = targets[0];
var models = targets.Select(t => t.Model).ToList();
var single = targets.Count == 1;
// 키 목록은 <b>합집합</b>이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고,
// 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다.
var advancedKeys = new List<string>();
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
foreach (var model in models)
{
foreach (var key in model.Props.Keys)
{
if (!curatedKeys.Contains(key) && seenKeys.Add(key))
{
advancedKeys.Add(key);
}
}
}
if (!showAdvanced)
{
@@ -619,6 +753,36 @@ public sealed class InspectorViewModel : ViewModelBase
foreach (var key in advancedKeys)
{
// 다중 선택은 대상 <b>전체</b>를 보고 판정한다 — 셋 다 통과해야 편집을 연다.
if (!single)
{
if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict)
{
AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})"));
continue;
}
var having = BatchEditPolicy.CountHaving(key, models);
if (having < models.Count)
{
// 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성'
// 이라며 모달을 띄운다 — 만들지 않는다
AddPlain(new ReadOnlyRowViewModel(key,
$"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)"));
continue;
}
if (!BatchEditPolicy.AllShareTextShape(key, models))
{
AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)"));
continue;
}
AddRow(new TextRowViewModel(key), new RowBinding
{
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
});
continue;
}
var value = target.Model.Props.Get(key);
switch (value)
{
@@ -663,6 +827,13 @@ public sealed class InspectorViewModel : ViewModelBase
}
}
// 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 <b>하나</b> 기준이라
// 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
if (!single)
{
return;
}
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
@@ -679,7 +850,16 @@ public sealed class InspectorViewModel : ViewModelBase
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
}
private void AddDefRow(PropertyDef def, string controlType)
/// <summary>
/// 컨트롤 모델 → 직계 부모 모델(최상위면 null).
///
/// 라디오 배타 판정에 쓴다 — 레거시 라디오는 <c>GroupName</c> 이 없어 <b>직계 부모가 곧 문항</b>이다.
/// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 <c>Parent</c> 를 돌려준다.
/// </summary>
private ControlElement? ParentModelOf(ControlElement model)
=> designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model;
private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList<ControlElement> targets)
{
var binding = def.Editor == PropEditorKind.StringList
? new RowBinding
@@ -696,6 +876,7 @@ public sealed class InspectorViewModel : ViewModelBase
}
vm.Model.Props.Set(def.Key, itemsValue);
},
ConfirmBatch = ConfirmDestructive(def.Key),
}
: new RowBinding
{
@@ -720,6 +901,7 @@ public sealed class InspectorViewModel : ViewModelBase
vm.Model.Props.SetText(def.Key, value);
}
},
ConfirmBatch = ConfirmDestructive(def.Key),
};
PropertyRowViewModel row = def.Editor switch
@@ -736,24 +918,109 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
// 액션은 값이 아니라 동작이다 — 피커가 데이터 태그용 값·사유 칸을 그리면 안 된다
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)) { IsActionTag = true },
// 컨트롤 이름을 넘긴다 — 안 넘기면 인스펙터로 연 편집기에서만 《컨트롤명》 칩이 사라져
// 같은 창이 진입 경로에 따라 능력이 달라진다
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label) { ControlNamesOf = ControlNames },
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames)
{
// 컬럼 목록과 대상 컨트롤 이름도 함께 준다 — 창이 사용자를 밖으로 내보내지 않게 한다
OwnerLabelOf = () => designer.Selection.Primary?.Id ?? string.Empty,
ColumnsOf = DataSourceColumns,
},
_ => new TextRowViewModel(def.Label),
};
AddRow(row, binding);
// 선의 방향·굵기는 값만 바꾸는 것이 아니라 <b>크기까지</b> 바꾼다(ApplyLineChange).
// 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다.
AddRow(row, binding, def.Key is "Orientation" or "BorderWidth"
? () => designer.Selection.NotifyBoundsChanged()
: null);
}
/// <summary>
/// 여러 대상의 <b>고유값을 한꺼번에 덮는</b> 편집에만 붙이는 확인 — 텍스트·항목 목록.
///
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고,
/// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다.
/// </summary>
private static Func<int, bool>? ConfirmDestructive(string key) => key is "Text" or "Items"
? count => Services.DialogService.Confirm("일괄 변경",
$"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.",
"각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).")
: null;
/// <summary>
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
///
/// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
/// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
/// </summary>
/// <summary>
/// 이 서식의 모든 컨트롤 이름 — 쿼리 편집기의 《컨트롤명》 치환 후보다.
///
/// 데이터소스 아이콘에서 편집기를 열면 이 목록이 넘어가는데 인스펙터의 쿼리 행에서는
/// 안 넘어가서, 같은 창이 진입 경로에 따라 능력이 달랐다.
/// </summary>
private IReadOnlyList<string> ControlNames()
{
var names = new List<string>();
void Visit(SheetMe.Core.Models.ControlElement element)
{
if (element.Id.Length > 0)
{
names.Add(element.Id);
}
foreach (var child in element.Children)
{
Visit(child);
}
}
foreach (var page in designer.Document.Pages)
{
foreach (var control in page.Controls)
{
Visit(control);
}
}
names.Sort(StringComparer.Ordinal);
return names;
}
/// <summary>
/// 데이터소스의 조회 결과 컬럼 이름 — 못 읽으면 null(미접속·쿼리 실패).
///
/// 값 가져오기 창이 컬럼을 목록으로 보여 줄 수 있게 하는 재료다. 전에는 그 창이
/// "컬럼 후보는 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
///
/// 실행은 <see cref="Services.MDataTableRunner"/> 가 한다 — 접속·치환·캐시·실패 처리가
/// 이미 그 한 군데에 있고, 여기서 다시 만들면 두 벌이 된다.
/// 러너가 데이터소스별로 결과를 캐시하므로 창이 여러 번 물어도 조회는 한 번이다.
/// </summary>
private IReadOnlyList<string>? DataSourceColumns(string name)
{
try
{
var patient = Services.PatientSession.Current;
var runner = new Services.MDataTableRunner(designer.Document,
patient is null ? null
: new Services.PatientQueryVariableSource(patient, Services.UserSession.Current.UidCod));
var result = runner.Run(name);
return result.Ok && result.Columns.Count > 0 ? result.Columns : null;
}
catch
{
// 컬럼 목록은 <b>도움</b>이다 — 못 읽는다고 창이 안 열려서는 안 된다.
// 창은 null 을 받으면 이유를 적고 직접 입력 칸을 남긴다.
return null;
}
}
private IReadOnlyList<string> DataTableNames()
{
var names = new List<string>();
@@ -832,6 +1099,8 @@ public sealed class InspectorViewModel : ViewModelBase
set(vm, Math.Round(number));
}
},
// 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
AffectsVisual = false,
};
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
@@ -839,9 +1108,16 @@ public sealed class InspectorViewModel : ViewModelBase
return row;
}
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴에서 고른다(직접 입력도 그대로 된다).
///
/// 전에는 자유 입력 칸 하나뿐이라 "돋움"을 매번 타이핑해야 했다. 그리고 한 글자만 틀려도
/// 레거시는 아무 말 없이 기본 글꼴로 떨어진다 — 디자이너에서는 멀쩡해 보이고
/// EMR 에서만 다른 글꼴로 나온다.
/// </summary>
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
{
AddRow(new TextRowViewModel(label), new RowBinding
AddRow(NewFontFamilyRow(label), new RowBinding
{
Get = vm => get(vm.EffectiveFont),
Set = (vm, value) =>
@@ -868,6 +1144,10 @@ public sealed class InspectorViewModel : ViewModelBase
set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
},
// 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
// "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
&& size > 0,
});
}
@@ -902,6 +1182,16 @@ public sealed class InspectorViewModel : ViewModelBase
return new SegmentItem(icon, label, toggle);
}
/// <summary>
/// 글꼴 이름 행 생성 — 컨트롤 글꼴과 페이지 기본 글꼴이 같은 것을 쓴다.
///
/// 목록은 <b>한글 이름</b>이다. 레거시가 저장하는 값이 그것이라("굴림, 11.25pt")
/// 영문 이름을 쓰면 고르는 순간 서식의 글꼴 이름이 바뀌고, 레거시가 그것을 못 찾으면
/// 조용히 기본 글꼴로 떨어진다.
/// </summary>
private static FontFamilyRowViewModel NewFontFamilyRow(string label)
=> new(label, SheetMe.Designer.Controls.InstalledFontCatalog.Families(), SheetMe.Designer.Controls.InstalledFontCatalog.IsInstalled);
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
private static LegacyFont CurrentFontOf(ControlViewModel vm)
{
@@ -957,6 +1247,12 @@ public sealed class InspectorViewModel : ViewModelBase
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
if (row is ChoiceRowViewModel choice)
{
// 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
// 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
if (values.Count > 1)
{
choice.EnsureMixedPlaceholder();
}
choice.EnsureChoice(initial);
}
@@ -969,6 +1265,27 @@ public sealed class InspectorViewModel : ViewModelBase
{
return;
}
// ── 스냅샷 <b>앞</b>의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데
// 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다.
if (binding.Validate is { } validate && !validate(value))
{
// 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이
// 다음 재구성까지 남아 값인 척하는 것을 막는다
var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1);
return;
}
// 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다)
var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal))
{
return;
}
if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count))
{
row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1);
return;
}
// 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
if (row.CoalesceUndo)
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; }
/// <summary>
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
///
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
/// </summary>
protected const string MixedSummary = "(여러 값)";
/// <summary>
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
get => valueText;
set
{
// null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
// (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
// 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
// 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
value ??= string.Empty;
if (!SetProperty(ref valueText, value) || building)
{
return;
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
get => IsMixed ? null : ValueText == "True";
set
{
if (value is bool on)
if (value is not bool on)
{
ValueText = on ? "True" : "False";
return;
}
// <b>갈린 상태에서 누르면 켠다.</b> WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
// IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
// 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
// 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
// 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이
// 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다.
if (IsMixed && !on)
{
on = true;
}
ValueText = on ? "True" : "False";
}
}
@@ -377,6 +402,47 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
}
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴 목록에서 고르거나 직접 입력한다.
///
/// <b>왜 자유 입력만으로는 안 되나.</b> 전에는 그냥 텍스트 칸이라 "돋움"을 매번 타이핑해야 했고,
/// 한 글자만 틀려도 조용히 기본 글꼴로 떨어졌다(레거시는 못 찾은 이름에 대해 아무 말도 하지 않는다).
///
/// <b>왜 목록만으로도 안 되나.</b> 병원 PC 와 이 PC 의 설치 글꼴이 다르다. 목록에 가두면
/// 여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고, 손대는 순간 사라진다.
/// 그래서 <b>고르기도 되고 쓰기도 되는</b> 칸이다.
/// </summary>
public sealed class FontFamilyRowViewModel : PropertyRowViewModel
{
private readonly Func<string, bool> isInstalled;
/// <summary>선택지 — 이 PC 에 설치된 글꼴</summary>
public IReadOnlyList<string> Families { get; }
/// <summary>
/// 지금 값이 이 PC 에 없는 글꼴인가 — 그러면 <b>이 화면이 병원과 다르게 보인다</b>.
/// 서식이 틀린 것이 아니므로 값은 건드리지 않고 알리기만 한다.
/// </summary>
public bool IsMissingFont => !IsMixed && ValueText.Length > 0 && !isInstalled(ValueText);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string MissingHint => $"'{ValueText}' 은(는) 이 PC 에 설치되어 있지 않습니다. "
+ "저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다.";
public FontFamilyRowViewModel(string label, IReadOnlyList<string> families, Func<string, bool> isInstalled)
: base(label)
{
Families = families;
this.isInstalled = isInstalled;
}
protected override void OnValueApplied()
{
OnPropertyChanged(nameof(IsMissingFont));
OnPropertyChanged(nameof(MissingHint));
}
}
/// <summary>선택지 행</summary>
public sealed class ChoiceRowViewModel : PropertyRowViewModel
{
@@ -402,6 +468,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
choices.Add(value);
}
}
/// <summary>
/// 값이 갈린 다중 선택에서 <b>'여러 값' 자리를 목록 맨 앞에 만든다</b>(빈 문자열).
///
/// 두 가지를 동시에 해결한다.
/// ① <b>보이게 한다.</b> 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
/// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
/// ② <b>코어스를 막는다.</b> <see cref="EnsureChoice"/> 는 IsNullOrEmpty 에서 빠져나가므로
/// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
/// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
/// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
///
/// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
/// </summary>
public void EnsureMixedPlaceholder()
{
if (!choices.Contains(string.Empty, StringComparer.Ordinal))
{
choices.Insert(0, string.Empty);
}
}
}
/// <summary>
@@ -416,6 +503,29 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
/// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; }
/// <summary>
/// 액션 태그 행인가 — 액션은 <b>값이 아니라 동작</b>이라 피커의 값·사유 칸 뜻이 다르다.
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 그 칸이 거짓이 된다.
/// </summary>
public bool IsActionTag { get; init; }
/// <summary>
/// 카탈로그에 없는 이름인가 — <b>값은 건드리지 않고</b> 표식만 낸다.
///
/// 인라인 완성으로 직접 치는 것이 이 행의 주 경로인데 거기에는 검증이 하나도 없었다.
/// 'PAT_이룸' 처럼 한 글자 틀리면 아무 표시 없이 커밋되고, 그 사실은 미리보기에서
/// <b>빈칸</b>으로만 드러난다 — 빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 수 없다.
///
/// 막지는 않는다. 사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다(글꼴 행과 같은 규칙).
/// </summary>
public bool IsUnknownTag => !IsMixed && ValueText.Length > 0
&& !string.Equals(ValueText, "None", StringComparison.Ordinal)
&& !Choices.Contains(ValueText, StringComparer.Ordinal);
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
public string UnknownTagHint => $"'{ValueText}' 은(는) 카탈로그에 없는 이름입니다. "
+ "사이트 전용 태그면 그대로 두고, 오타면 고치세요 — 값이 안 나오면 종이에서 빈칸이 됩니다.";
/// <summary>
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
@@ -433,11 +543,134 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public IReadOnlyList<string> Suggestions { get; }
/// <summary>
/// 제안을 보여 줄지 — <b>값이 비어 있을 때만</b>.
/// 이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다.
/// 제안이 필요한 순간은 '아직 안 골랐을 때'다.
/// 제안을 보여 줄지.
///
/// 전에는 <c>ValueText.Length == 0</c> 이었다 — 값이 정해지면 칩이 사라졌다.
/// 그래서 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b>. 실사용에서 태그를 고치는 일은
/// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다.
/// 이제 값이 있어도 남긴다. 대신 <b>타이핑을 시작하면 접는다</b> —
/// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다.
/// </summary>
public bool ShowSuggestions => ValueText.Length == 0 && Suggestions.Count > 0;
public bool ShowSuggestions => !IsTyping && Suggestions.Count > 0;
/// <summary>
/// 인라인 완성 목록 — 이 행에서 모달 없이 태그를 고르는 주 경로.
///
/// <b>왜 여기가 주 경로인가.</b> 칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
/// 나머지는 이름을 정확히 16자 외워 치거나 1080×700 모달을 열어야 했고,
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고
/// EMR 에서 조용히 빈 칸이 됐다</b>.
/// 같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있다 — 패턴은 있는데
/// 운영 8,032건이 걸린 칸에만 없었다.
/// </summary>
public System.Collections.ObjectModel.ObservableCollection<string> Completions { get; } = new();
/// <summary>완성 목록에서 지금 짚고 있는 줄 — ↑↓ 로 움직이고 Enter 로 확정한다</summary>
public int CompletionIndex
{
get => completionIndex;
set => SetProperty(ref completionIndex, value);
}
public bool ShowCompletions
{
get => showCompletions;
private set => SetProperty(ref showCompletions, value);
}
/// <summary>타이핑 중인가 — 칩을 접고 완성을 여는 기준</summary>
private bool IsTyping { get; set; }
private int completionIndex = -1;
private bool showCompletions;
/// <summary>
/// 지금 입력된 글자로 완성 목록을 다시 만든다.
///
/// 순위는 <b>이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름</b>이다.
/// 알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
/// 검색은 <see cref="Core.Catalog.TagSearch"/> 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다.
/// </summary>
public void RefreshCompletions(string typed)
{
// 인자로 받는다 — 태그 행의 Text 바인딩은 UpdateSourceTrigger=LostFocus 라
// 타이핑 중에는 ValueText 가 아직 옛 값이다. 그것으로 목록을 만들면 한 글자 뒤처진다.
var query = typed.Trim();
Completions.Clear();
if (!IsTyping || query.Length == 0)
{
ShowCompletions = false;
CompletionIndex = -1;
return;
}
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var ranked = Choices
.Where(tag => Core.Catalog.TagSearch.MatchesAll(tag, tokens))
.OrderBy(tag =>
{
var rank = preferredAll.ToList().IndexOf(tag);
return rank < 0 ? int.MaxValue : rank;
})
.ThenByDescending(Core.Catalog.LegacyTagUsageCounts.Of)
.ThenBy(tag => tag, StringComparer.Ordinal)
.Take(CompletionLimit)
.ToList();
foreach (var tag in ranked)
{
Completions.Add(tag);
}
// 이미 정확히 그 태그만 남았으면 목록을 띄우지 않는다 — 다 골랐는데 가리는 셈이다
ShowCompletions = ranked.Count > 0
&& !(ranked.Count == 1 && string.Equals(ranked[0], query, StringComparison.Ordinal));
CompletionIndex = ShowCompletions ? 0 : -1;
}
/// <summary>사용자가 글자를 넣기 시작했다 — 칩을 접고 완성을 연다</summary>
public void BeginTyping()
{
if (IsTyping)
{
return;
}
IsTyping = true;
OnPropertyChanged(nameof(ShowSuggestions));
}
/// <summary>편집이 끝났다(확정·취소·포커스 이동) — 칩을 되돌리고 완성을 닫는다</summary>
public void EndTyping()
{
IsTyping = false;
ShowCompletions = false;
CompletionIndex = -1;
Completions.Clear();
OnPropertyChanged(nameof(ShowSuggestions));
}
/// <summary>↑↓ — 목록을 벗어나지 않고 순환한다</summary>
public void MoveCompletion(int delta)
{
if (Completions.Count == 0)
{
return;
}
CompletionIndex = ((CompletionIndex + delta) % Completions.Count + Completions.Count) % Completions.Count;
}
/// <summary>짚고 있는 줄을 값으로 확정한다 — 확정했으면 true</summary>
public bool CommitCompletion()
{
if (!ShowCompletions || CompletionIndex < 0 || CompletionIndex >= Completions.Count)
{
return false;
}
var picked = Completions[CompletionIndex];
EndTyping();
ValueText = picked;
return true;
}
/// <summary>완성 목록에 깔 줄 수 — 10행×24px 이면 인스펙터를 덮지 않는다</summary>
private const int CompletionLimit = 10;
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
@@ -466,13 +699,16 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
if (e.PropertyName == nameof(ValueText))
{
OnPropertyChanged(nameof(ShowSuggestions));
// 값이 바뀌면 '카탈로그에 없음' 표식도 다시 판정한다
OnPropertyChanged(nameof(IsUnknownTag));
OnPropertyChanged(nameof(UnknownTagHint));
}
};
}
private void OnBrowse()
{
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll)
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll, IsActionTag)
{
Owner = System.Windows.Application.Current.MainWindow,
};
@@ -490,7 +726,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public sealed class MaskRowViewModel : PropertyRowViewModel
{
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => ValueText.Length == 0
public string Preview => IsMixed
? MixedSummary
: ValueText.Length == 0
? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
@@ -525,6 +763,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
{
get
{
if (IsMixed)
{
return MixedSummary;
}
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
}
@@ -540,9 +782,20 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
/// <summary>
/// 이 서식의 컨트롤 이름 — 편집기의 《컨트롤명》 치환 후보가 된다.
///
/// 안 넘기면 <b>같은 창이 진입 경로에 따라 능력이 달라진다</b> —
/// 데이터소스 아이콘에서 열면 컨트롤 변수 칩이 나오는데
/// 인스펙터의 쿼리 행에서 열면 그 칩이 통째로 사라졌다.
/// 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
/// </summary>
public Func<IReadOnlyList<string>>? ControlNamesOf { get; init; }
private void OnEdit()
{
var dialog = new Views.QueryEditorWindow(Label, ValueText)
var dialog = new Views.QueryEditorWindow(Label, ValueText,
showVariables: true, controlNames: ControlNamesOf?.Invoke())
{
Owner = System.Windows.Application.Current.MainWindow,
};
@@ -715,26 +968,56 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{
private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary>
/// <summary>
/// 사람이 읽는 요약. <b>'배선' 이라는 낱말을 쓰지 않는다</b> —
/// 전산실에서 배선은 랜선·전원이고, '배선 없음' 은 데이터소스가 없는 것처럼 읽힌다.
/// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같게</b> 둔다 —
/// 그 버튼을 누르면 무엇이 되는지 미리 보이게 하려는 것이다.
/// </summary>
public string Summary
{
get
{
if (IsMixed)
{
return MixedSummary;
}
if (ValueText.Length == 0)
{
return "(배선 없음)";
return "(가져오지 않음)";
}
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
return spec is null
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
? $"⚠ 형식을 알 수 없음: {ValueText}"
: $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행");
// 행 0 은 '첫 행' 이다. 그 밖은 사람이 세는 번호(0 → 1번째)로 적는다.
// 조건이 있으면 그 사실을 덧붙인다 — 조건이 화면 어디에도 안 보인 채
// 결과를 바꾸는 상태를 남기지 않는다.
: $"{spec.TableName} · {spec.Field}"
+ (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex + 1}번째 행")
+ (spec.Form == SheetMe.Core.Catalog.DataTableFieldForm.Select && spec.Filter.Length > 0
? " · 조건 있음" : string.Empty);
}
}
/// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
/// <summary>
/// 대상 컨트롤 이름 — 창 제목에 넣는다. 모달이라 인스펙터가 가려져서,
/// 여러 칸을 연달아 채울 때 지금 어느 칸 차례인지 알 방법이 없었다.
/// </summary>
public Func<string>? OwnerLabelOf { get; init; }
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름(없으면 null).
///
/// 전에는 창이 '컬럼 후보는 목록으로 제시하지 않습니다' 라고 적어 두고 사용자를 밖으로 보냈는데,
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
/// 실행은 이 행이 하지 않는다(호출부가 이미 만들어 둔 러너를 함수로 받는다).
/// </summary>
public Func<string, IReadOnlyList<string>?>? ColumnsOf { get; init; }
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
{
this.tablesOf = tablesOf;
@@ -750,7 +1033,8 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
private void OnEdit()
{
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf())
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf(),
OwnerLabelOf?.Invoke(), ColumnsOf)
{
Owner = System.Windows.Application.Current.MainWindow,
};
@@ -864,4 +1148,22 @@ public sealed class RowBinding
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true;
/// <summary>
/// 커밋 전 값 검사 — false 면 <b>아무것도 하지 않고</b> 칸을 원래 표시로 되돌린다.
///
/// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
/// TryParse 에 실패해 아무것도 안 바뀌었다 — <b>바뀐 것이 없는데 문서는 '수정됨'</b>이 되고
/// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
/// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
/// </summary>
public Func<string, bool>? Validate { get; init; }
/// <summary>
/// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
///
/// 대상마다 <b>서로 다른 고유값</b>이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
/// </summary>
public Func<int, bool>? ConfirmBatch { get; init; }
}
+173 -46
View File
@@ -168,6 +168,25 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
public bool CanUseDb => dataBusiness.CanUseDb;
/// <summary>
/// DB 저장이 가능한 단말인가 — 저장 메뉴·버튼의 활성 조건.
///
/// 전에는 항상 활성이었고 누른 뒤에야 경고가 떴다. 이제 저장이 DB 로만 가므로
/// 못 쓰는 상태를 <b>누르기 전에</b> 보여 줘야 한다.
/// SaveMode 기본값이 File 인 데는 이유가 있다 — 배포된 ini 의 CurrentServer 가 운영 병원 DB 를
/// 가리켜서, 명시적으로 켠 단말에서만 운영 테이블에 쓰게 하려는 장치다(docs/DEPLOYMENT.md:54-56).
/// 그 규칙은 그대로 두고 화면이 상태를 말하게 한다.
/// </summary>
public bool CanSaveToDb => dataBusiness.CanSaveToDb;
/// <summary>
/// '수정'(제자리 정정)을 이 단말·이 사용자가 쓸 수 있는가.
/// 레거시는 이 버튼만 전산실에 묶어 두었다 — 저장 버튼에는 그런 분기가 없다.
/// </summary>
public bool CanModifyInDb => dataBusiness.CanSaveToDb && FormDesignDataBusiness.CanModify();
public ICustomCommand? ModifyInDbCommand { get; set; }
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
public int SelectedLeftTabIndex
{
@@ -187,12 +206,23 @@ internal sealed class MainViewModel : ViewModelBase
public MainViewModel()
{
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile());
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
// Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 —
// DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다.
// 사용자는 저장했다고 믿지만 <b>DB 에는 아무것도 안 들어간다</b>.
// 종료 가드는 이미 IsFromDb 로 갈라 준다(:365) — Ctrl+S 만 안 갈라 주고 있었다.
SaveFileCommand = new Command((sender, e) =>
{
if (CurrentDesigner?.IsFromDb == true)
{
OnSaveToDb();
return;
}
OnSaveFile(saveAs: false);
});
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
ModifyInDbCommand = new Command((sender, e) => OnModifyInDb());
ExportJsonCommand = new Command((sender, e) => OnExportJson());
ImportJsonCommand = new Command((sender, e) => OnImportJson());
PreviewCommand = new Command((sender, e) => OnPreview());
@@ -227,9 +257,6 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>창 로드</summary>
public ICustomCommand? LoadedCommand { get; set; }
/// <summary>새 서식</summary>
public ICustomCommand? NewFileCommand { get; set; }
/// <summary>서식 XML 열기</summary>
public ICustomCommand? OpenFileCommand { get; set; }
@@ -240,7 +267,6 @@ internal sealed class MainViewModel : ViewModelBase
public ICustomCommand? SaveAsFileCommand { get; set; }
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
public ICustomCommand? OpenFromDbCommand { get; set; }
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
public ICustomCommand? SaveToDbCommand { get; set; }
@@ -362,9 +388,20 @@ internal sealed class MainViewModel : ViewModelBase
{
continue;
}
if (!(designer.IsFromDb ? OnSaveToDb() : OnSaveFile(saveAs: false)))
if (OnSaveToDb())
{
return false; // 저장 대화상자 취소 또는 오류 — 종료를 중단한다
continue;
}
// 저장이 실패했다 — 여기서 무조건 종료를 막으면 <b>앱을 닫을 수 없는 상태</b>가 된다.
// 저장은 DB 로만 가고, SaveMode=File 단말이나 접속 불가 상태에서는 갈 곳이 없기 때문이다.
// 전에는 파일 저장 폴백이 있어서 이 분기가 드물었지만 이제는 흔한 경로다.
// 그러니 막지 말고 <b>버리고 닫을지</b>를 묻는다 — 사용자가 갇히는 것이 더 나쁘다.
if (!DialogService.Confirm("종료",
$"'{designer.DisplayName}' 을(를) 저장하지 못했습니다.",
"변경을 버리고 종료할까요? 저장하려면 '취소'를 누르고 원인을 확인하세요.",
yes: "버리고 종료", no: "취소", destructive: true))
{
return false;
}
}
return true;
@@ -456,8 +493,7 @@ internal sealed class MainViewModel : ViewModelBase
{
await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
// 기동 인자로 서식을 지정했으면 그것만 연다 — 레거시도 그렇다.
if (PendingSheetCode is { Length: > 0 } shtCod)
{
PendingSheetCode = null;
@@ -468,22 +504,22 @@ internal sealed class MainViewModel : ViewModelBase
}
}
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
// 아무것도 열지 않은 상태로 둔다. 전에는 빈 새 문서를 만들어 두었는데,
// 그건 <b>저장할 수 없는 문서</b>다 — 서식 코드가 없으니 DB 저장이 막히고,
// 사용자는 한참 그려 넣은 뒤에야 그 사실을 알게 된다.
// 서식은 반드시 목록에서 골라 연다(그래야 코드·권한·기존 디자인이 함께 온다).
SelectedLeftTabIndex = 0;
StatusText = "서식 목록에서 서식을 골라 여세요.";
}
private void OnNewFile()
{
try
{
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
StatusText = "새 서식 (720×856)";
}
catch (Exception ex)
{
DialogService.ShowError("새 서식 만들기", ex);
}
}
/// <summary>
/// 빈 문서를 붙인다 — <b>진단 전용</b>이다(--dialog-shots 가 화면을 찍으려면 문서가 필요하다).
///
/// UI 에는 '새 서식'이 없다. 코드 없는 문서는 DB 에 저장할 수 없고,
/// 그린 뒤에 그것을 알게 되는 것이 이 앱에서 가장 비싼 실수였다.
/// </summary>
internal void AttachBlankForDiagnostics()
=> AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
private void OnOpenFile()
{
@@ -559,30 +595,97 @@ internal sealed class MainViewModel : ViewModelBase
}
}
private void OnOpenFromDb()
/// <summary>
/// 제자리 정정 — 레거시 '기록지 수정'.
///
/// 저장과 무엇이 다른가: 저장은 새 버전을 만들어 그것이 활성이 되고 옛 것이 이력이 된다.
/// 수정은 <b>지금 쓰이는 버전을 그 자리에서 고친다</b> — SdgKey 가 안 바뀌므로
/// EMR 이 참조하던 키가 유지되고, 옛 내용만 이력으로 떨어진다.
///
/// 그래서 새 컨트롤을 <b>막는다</b>. 수정은 E_SctMst 를 안 건드리므로 새 컨트롤은 매핑 행 없이
/// 저장되고 EMR 에서 조용히 값이 안 채워진다. 레거시는 이것을 산문으로만 경고했고
/// ("새로운 컨트롤이 추가될 경우 수정이 아닌 저장을 이용하시기 바랍니다") 막지는 않았다.
/// 경고문을 읽지 않으면 그대로 번지므로 여기서는 이름을 대조해 막는다.
/// </summary>
private bool OnModifyInDb()
{
if (CurrentDesigner is not { } designer)
{
return false;
}
try
{
if (!EnsureDb())
if (!EnsureDb() || !dataBusiness.CanWriteDb)
{
return;
DialogService.Notify(DialogKind.Warning, "수정",
"HIS 사용자가 확인되지 않아 수정할 수 없습니다.",
"이력에 남길 수정자를 특정할 수 없습니다.");
return false;
}
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
// 저장 경로와 같은 게이트 — 열기 이후 마스터에서 플래그가 꺼졌을 수 있다
if (!EnsureDesignAllowed(designer.Document.FormId))
{
Owner = Application.Current.MainWindow,
};
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
{
return;
return false;
}
OpenDbSheet(dialog.SelectedSheet.ShtCod);
if (!FormDesignDataBusiness.CanModify())
{
DialogService.Notify(DialogKind.Warning, "수정 권한 없음",
FormDesignDataBusiness.ModifyDenyReason(),
"저장(새 버전 만들기)은 그대로 쓸 수 있습니다.");
return false;
}
CommitPendingEdits();
var document = designer.Document;
var added = dataBusiness.NewControlNames(document);
if (added.Count > 0)
{
var sample = string.Join(", ", added.Take(5));
DialogService.Notify(DialogKind.Warning, "수정할 수 없습니다",
$"이 서식에 새 컨트롤 {added.Count}개가 추가되었습니다 — {sample}"
+ (added.Count > 5 ? " 외" : string.Empty),
"수정은 지금 버전의 글자만 고칩니다. 새 컨트롤은 EMR 연결 정보가 만들어지지 않아 "
+ "화면에서 값이 비게 됩니다. '저장'으로 새 버전을 만드세요.");
return false;
}
if (!DialogService.Confirm("수정 확인",
$"[{document.FormId}] {document.Title} — 지금 쓰이는 버전을 그 자리에서 고칩니다.",
"새 버전을 만들지 않으므로 EMR 이 참조하는 키가 그대로 유지됩니다.\n"
+ "지금 내용은 이력으로 남습니다.\n\n"
+ "기술 정보 — 활성 SdgKey 의 SdgDesign 을 갱신하고, 옛 XML 을 SdgDelYon='Y' 행으로 넣습니다. "
+ "E_SctMst 는 건드리지 않습니다.",
yes: "수정", no: "취소"))
{
return false;
}
var sdgKey = dataBusiness.ModifyInDb(document);
designer.IsFromDb = true;
designer.Undo.MarkSaved();
designer.NotifyDisplayNameChanged();
StatusText = $"수정 완료: {document.FormId} → SdgKey {sdgKey} 제자리 갱신";
_ = LoadSheetListAsync();
return true;
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "오류", "DB에서 서식을 여는 중 오류가 발생했습니다.", ex.Message);
DialogService.Notify(DialogKind.Error, "오류",
"수정 중 오류가 발생했습니다. (트랜잭션 롤백됨)", ex.Message);
return false;
}
}
// 'DB에서 열기' 대화상자(680×560)를 지웠다.
//
// 좌측 '서식 목록' 탭이 <b>같은 조회</b>를 상시로 한다(둘 다 ListSheets, WHERE·ORDER BY·상한 동일).
// 다만 같은 조회가 곧 같은 쓸모는 아니라, 그 창이 덮고 있던 구멍 넷을 먼저 좌측에 옮겼다:
// ① 저장·신규 등록 뒤 재조회 — 이게 없어서 방금 저장한 서식이 목록에 안 떴다(가장 컸다)
// ② 서식명 툴팁 — 좌측은 폭이 148px 남짓이라 긴 이름을 볼 방법이 없었다
// ③ '디자인 없음'과 '서식생성기 사용 꺼짐'이 같은 회색이던 것을 툴팁으로 갈랐다
// ④ Enter 로 열기 — 좌측은 더블클릭 전용이라 키보드로는 열 수 없었다
// Ctrl+O 도 여기로 온다(전에는 OS 파일 대화상자였다).
/// <summary>
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
@@ -667,10 +770,22 @@ internal sealed class MainViewModel : ViewModelBase
document.Title = register.SheetName;
}
// 문면을 두 층으로 가른다.
//
// 이 대화상자는 <b>세 부류가 다 보는 유일한 위험 지점</b>이다 —
// 사내 개발·지원, 병원 전산팀, 의료진·간호부. 그런데 본문에
// E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
// 사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고,
// 간호부에게는 공포다. 위층은 사람 말로, 테이블·컬럼은 접힌 아래층에 둔다
// (상세는 접혀 있고 사내 인력이 펼쳐 본다).
var inPlace = "제자리 갱신 서식이면 지금 버전이 바로 갱신됩니다.";
var confirm = DialogService.Confirm("DB 저장 확인",
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?",
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
$"[{document.FormId}] {document.Title} — 이 서식의 새 버전을 만듭니다.",
"지금 쓰이는 버전은 이력으로 남고, 앞으로는 새 버전이 열립니다.\n"
+ inPlace + "\n\n"
+ "기술 정보 — E_SdgMst/E_SctMst 에 기록합니다. "
+ "기존 활성 디자인은 SdgDelYon='Y' 로 이력 전환되고 새 SdgKey 가 생성됩니다. "
+ "ShtCneYon='Y' 서식은 기존 버전을 갱신합니다.",
yes: "저장", no: "취소");
if (!confirm)
{
@@ -697,9 +812,19 @@ internal sealed class MainViewModel : ViewModelBase
$"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.",
"그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요.");
}
// 완료 알림 모달을 띄우지 않는다. 정보량이 0 이었다 — SdgKey 는 바로 위에서 상태바에 넣고,
// 같은 값을 모달로 한 번 더 보여 주는 것이었다.
// 하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고,
// 그때마다 화면 전체가 어두워졌다(스크림). 실패는 계속 모달로 알린다 —
// 그건 진행이 정의되지 않는 상태이고, 조용히 넘기면 저장된 줄 알고 넘어간다.
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
DialogService.Notify(DialogKind.Info, "DB 저장",
$"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요.");
// 좌측 목록을 다시 읽는다.
//
// 목록은 기동 때 한 번과 검색 버튼으로만 채워졌고, 저장·신규 등록 뒤에 갱신되지 않았다.
// 그래서 <b>방금 저장한 서식이 목록에 안 떴다</b> — 지금까지 그 구멍을
// 'DB에서 열기' 대화상자가 덮고 있었다(그 창은 열 때마다 재조회했다).
// 그 창을 지우려면 여기가 먼저 메워져야 한다.
_ = LoadSheetListAsync();
return true;
}
catch (Exception ex)
@@ -840,10 +965,9 @@ internal sealed class MainViewModel : ViewModelBase
{
return;
}
new Views.PreviewWindow(CurrentDesigner)
{
Owner = Application.Current.MainWindow,
}.Show();
// 창은 하나만 뜬다 — 여러 장이 겹치면 각각 다른 시점의 서식을 들고 있게 되고,
// 환자 데이터가 들어가면 옛 창이 옛 환자를 계속 띄운다.
Views.PreviewWindow.ShowFor(CurrentDesigner, Application.Current.MainWindow);
}
private void OnPrint()
@@ -854,9 +978,12 @@ internal sealed class MainViewModel : ViewModelBase
{
return;
}
// 미리보기를 안 거친 인쇄도 세션 환자를 따른다 — 해석기를 안 넘기면
// 태그 이름이 그대로 인쇄된다(미리보기 인쇄에서 실제로 그랬다).
var (tags, fields) = Services.PatientSession.ResolversFor(CurrentDesigner.Document);
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
? CurrentDesigner.Document.Title
: CurrentDesigner.Document.FormId);
: CurrentDesigner.Document.FormId, tags, fields);
}
catch (Exception ex)
{
@@ -55,6 +55,22 @@ public sealed class SpacingMarkInfo
public double TextY { get; init; }
}
/// <summary>잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다.</summary>
public sealed class LockMarkInfo
{
/// <summary>좌상단 X(월드)</summary>
public double X { get; init; }
/// <summary>좌상단 Y(월드)</summary>
public double Y { get; init; }
/// <summary>너비(월드)</summary>
public double W { get; init; }
/// <summary>높이(월드)</summary>
public double H { get; init; }
}
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
public sealed class TabBadgeInfo
{
@@ -100,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double selW;
private double selH;
private bool showHandles;
private string dragBadgeText = string.Empty;
private bool showDragBadge;
private double dragBadgeX;
private double dragBadgeY;
private bool hasMarquee;
private double marX;
private double marY;
@@ -172,6 +192,54 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
/// <summary>
/// 끄는 동안 보여 주는 치수·좌표 — <b>드래그 중에만</b> 있다.
///
/// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면
/// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다.
/// 간격 수치 칩은 <b>균등 스냅이 성립하는 순간에만</b> 나타나므로
/// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다.
/// </summary>
public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); }
/// <summary>치수 칩을 보일지</summary>
public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); }
/// <summary>치수 칩 X(월드) — 선택 상자 좌하단 바깥</summary>
public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); }
/// <summary>치수 칩 Y(월드)</summary>
public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); }
/// <summary>
/// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다.
/// 사용자가 지금 <b>바꾸고 있는 값</b>을 보여 주는 것이 요점이다.
/// </summary>
public void SetDragBadge(Rect bounds, bool resizing)
{
DragBadgeText = resizing
? $"{Math.Round(bounds.Width)} × {Math.Round(bounds.Height)}"
: $"X {Math.Round(bounds.X)} · Y {Math.Round(bounds.Y)}";
// 선택 상자 <b>아래</b>에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다
DragBadgeX = bounds.X;
DragBadgeY = bounds.Bottom + (6 * InverseZoom);
ShowDragBadge = true;
}
/// <summary>드래그가 끝났다 — 칩을 치운다</summary>
public void ClearDragBadge() => ShowDragBadge = false;
/// <summary>
/// 잠긴 컨트롤의 테두리 — <b>편집 크롬</b>이라 종이에는 그려지지 않는다.
///
/// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 <c>--db-render</c> 게이트가
/// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다.
///
/// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 <b>잠금은 아무 표시가 없었다</b>.
/// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다.
/// </summary>
public ObservableCollection<LockMarkInfo> LockMarks { get; } = new();
#endregion
#region Methods
@@ -0,0 +1,81 @@
<Window x:Class="SheetMe.Designer.Views.DataSourceResultDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터소스 조회 결과" Width="980" Height="640"
MinWidth="760" MinHeight="440"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="16">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="SQL 복사" Padding="14,4" Click="OnCopySql"
ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/>
<Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True" Style="{StaticResource Primary}"/>
</StackPanel>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="220"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"/>
<ListBox x:Name="SourceList" SelectionChanged="OnSourceChanged"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
AutomationProperties.Name="데이터소스 목록">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Orientation="Horizontal">
<!-- 실패한 것을 목록에서 바로 알아야 한다 — 하나씩 눌러 보게 만들면 안 된다 -->
<TextBlock Text="{Binding Mark}" Width="16" FontWeight="Bold">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Ok}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Name}"/>
<TextBlock Text="{Binding Suffix}" Margin="6,0,0,0"
Foreground="{DynamicResource B.Muted}"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="Transparent"/>
<DockPanel Grid.Column="2">
<!--
치환을 마친 SQL 이 이 창의 핵심이다. 값이 안 나왔을 때 사람이 봐야 하는 것은
결과가 아니라 <b>무엇을 물었는가</b>다 — 치환이 빈 값이 되면 조건이 사라진
SQL 이 되고, 그건 SQL 을 봐야만 알 수 있다.
-->
<TextBlock DockPanel.Dock="Top" Text="치환을 마친 SQL" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"/>
<TextBox x:Name="SqlBox" DockPanel.Dock="Top" Height="150" Margin="0,0,0,8"
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"
VerticalScrollBarVisibility="Auto" FontFamily="Consolas"
AutomationProperties.Name="치환을 마친 SQL"/>
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,6" TextWrapping="Wrap"/>
<DataGrid x:Name="ResultGrid" IsReadOnly="True" AutoGenerateColumns="True"
HeadersVisibility="Column" GridLinesVisibility="All"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
AutomationProperties.Name="조회 결과"/>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,144 @@
using System.Data;
using System.Windows;
using System.Windows.Controls;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Views;
/// <summary>
/// MDataTable 조회 결과를 펼쳐 본다.
///
/// <b>왜 이 창이 필요한가.</b> 종이에는 <c>[MDataTable1.ALGYON — 조회 결과가 0행입니다]</c>처럼
/// 한 줄만 나온다. 그것으로는 세 가지를 가릴 수 없다 —
/// 쿼리 자체가 틀렸는가, 치환이 빈 값이 되어 조건이 사라졌는가, 이 환자에게 정말 자료가 없는가.
/// 셋은 고칠 곳이 전부 다르다.
///
/// 그래서 <b>치환을 마친 SQL</b>을 그대로 보여 준다. 값이 안 나왔을 때 사람이 봐야 하는 것은
/// 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 —
/// 런타임이 빈 catch 로 삼켜서 현장에서 "빈칸"만 남았다.
/// </summary>
public partial class DataSourceResultDialogView : Window
{
#region Nested Types
/// <summary>목록 한 줄 — 실패한 것을 눌러 보지 않고도 알아야 한다</summary>
private sealed record Row(string Name, bool Ok, string Suffix, MDataTableResult Source)
{
public string Mark => Ok ? "✓" : "✕";
}
#endregion
#region Constructors
/// <summary>진단(--dialog-shots)이 직접 만든다 — 앱 코드는 ShowFor 를 쓸 것</summary>
internal DataSourceResultDialogView(MDataTableRunner runner)
{
InitializeComponent();
var results = runner.RunAll();
var rows = results.Select(r => new Row(
r.Name, r.Ok,
r.Ok ? $"{r.Rows.Count}행" : "실패",
r)).ToList();
SourceList.ItemsSource = rows;
HeaderText.Text = runner.HasContext
? $"데이터소스 {rows.Count}개 · 실패 {rows.Count(r => !r.Ok)}개."
+ " 최대 20행까지만 읽습니다(읽기 전용 · SELECT/WITH 만 실행)."
: "환자를 고르지 않았습니다 — 치환 변수가 전부 빈 값이 되어 결과가 실제와 다릅니다."
+ " 미리보기 창에서 '환자 선택'을 먼저 하세요.";
if (rows.Count > 0)
{
SourceList.SelectedIndex = 0;
}
}
#endregion
#region Methods
public static void ShowFor(MDataTableRunner runner, Window? owner)
=> new DataSourceResultDialogView(runner) { Owner = owner }.ShowDialog();
private void OnSourceChanged(object sender, SelectionChangedEventArgs e)
{
if (SourceList.SelectedItem is not Row row)
{
return;
}
var result = row.Source;
SqlBox.Text = result.Sql;
var notes = new List<string>();
if (result.Error is { Length: > 0 } error)
{
notes.Add("실패: " + error);
}
else
{
notes.Add($"{result.Rows.Count}행 · 컬럼 {result.Columns.Count}개");
}
if (result.Unknown.Count > 0)
{
// 여기가 가장 흔한 원인이다 — 아직 안 옮긴 변수가 빈 문자열로 박혀 조건이 사라진다.
// SQL 만 봐서는 "원래 그런 조건인지" 알 수 없으므로 따로 말한다.
notes.Add("아직 옮기지 않은 변수가 빈 값으로 들어갔습니다: "
+ string.Join(", ", result.Unknown));
}
StatusText.Text = string.Join(" · ", notes);
StatusText.Foreground = (System.Windows.Media.Brush)FindResource(
result.Ok && result.Unknown.Count == 0 ? "B.Muted" : "B.Danger");
ResultGrid.ItemsSource = ToTable(result)?.DefaultView;
}
/// <summary>
/// 결과를 DataGrid 가 받을 표로. 컬럼명이 중복되거나 비면 <c>DataTable</c> 이 예외를 낸다 —
/// 운영 쿼리에는 별칭 없는 표현식(<c>COUNT(*)</c> 등)이 흔하므로 여기서 이름을 만들어 준다.
/// </summary>
private static DataTable? ToTable(MDataTableResult result)
{
if (result.Columns.Count == 0)
{
return null;
}
var table = new DataTable();
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
foreach (var name in result.Columns)
{
var candidate = name.Length > 0 ? name : "컬럼";
var unique = candidate;
var n = 2;
while (!used.Add(unique))
{
unique = $"{candidate}_{n++}";
}
table.Columns.Add(unique, typeof(string));
}
foreach (var cells in result.Rows)
{
var values = new object[table.Columns.Count];
for (var i = 0; i < values.Length; i++)
{
values[i] = i < cells.Length ? cells[i] ?? string.Empty : string.Empty;
}
table.Rows.Add(values);
}
return table;
}
private void OnCopySql(object sender, RoutedEventArgs e)
{
if (SqlBox.Text.Length == 0)
{
return;
}
try
{
Clipboard.SetText(SqlBox.Text);
}
catch (Exception ex)
{
// 클립보드는 다른 프로세스가 잡고 있으면 실패한다 — 조용히 넘기면 복사된 줄 안다
DialogService.Notify(DialogKind.Warning, "복사", "클립보드에 넣지 못했습니다.", ex.Message, this);
}
}
#endregion
}
@@ -1,29 +1,58 @@
<Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터 필드 배선" Width="560" Height="520"
Title="값 가져오기" Width="620" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="14">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10"
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
Style="{StaticResource ThemedDialogWindow}">
<!--
이 창은 "조회 결과의 한 칸을 이 컨트롤에 채우는" 설정이다.
<!-- 아래쪽 버튼 -->
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/>
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0"
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<b>왜 다시 썼나.</b> 전에는 네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어서
넷 다 채워야 하는 것처럼 보였다. 정작 <b>필수는 둘</b>(데이터소스·컬럼)이고
그 둘이 화면에서 가장 눈에 안 띄는 자리에 있었다.
게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 라,
화면이 "무엇을 먼저 하나"의 답을 '지우기' 로 주고 있었다.
<!-- 조립 결과 미리보기 + 검증 메시지 -->
실사용 근거: 조건(필터)을 쓴 배선이 <b>0건</b>이고 행 번호는 98%가 0 이다.
그래서 그 둘은 접어 두고, 기본값이 아닐 때만 칩으로 드러낸다.
-->
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,12"
Text="서식이 열릴 때 데이터소스가 받아 온 조회 결과에서 한 칸을 읽어 이 칸을 자동으로 채웁니다. 고를 것은 둘입니다 — ① 어느 데이터소스에서, ② 그 결과의 어느 컬럼을."/>
<!-- 아래쪽 버튼. '가져오지 않음' 은 왼쪽으로 떼어 확인·취소와 섞이지 않게 한다 -->
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0" LastChildFill="False">
<Button DockPanel.Dock="Left" Content="가져오지 않음" Padding="12,4" Click="OnClear"
ToolTip="이 칸을 자동으로 채우지 않습니다 — 데이터소스나 쿼리는 지워지지 않습니다"/>
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
<Button x:Name="OkButton" Content="확인" Padding="18,4"
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</DockPanel>
<!-- 사람 말 한 줄 + 접힌 저장 형식 -->
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
<StackPanel>
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
<TextBlock x:Name="PlainText" TextWrapping="Wrap" FontSize="12.5"/>
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/>
<!--
저장 원문을 없애지 않고 접어 둔다. 인스펙터 툴팁·진단 덤프·레거시 문서가
전부 이 표기로 이야기되므로 원문을 들고 온 사람이 대조할 자리가 있어야 한다.
다만 큰 자리를 주면 창이 '문법 조립하는 곳' 으로 스스로를 재정의한다 —
아무것도 안 고른 상태의 첫 표시가 마침표로 시작하는 것도 그 탓이었다.
-->
<Expander x:Name="RawExpander" Margin="0,6,0,0" Header="저장 형식">
<DockPanel Margin="0,4,0,0">
<Button DockPanel.Dock="Right" Content="복사" Padding="8,2" VerticalAlignment="Top"
Margin="6,0,0,0" Click="OnCopyRaw"/>
<TextBlock x:Name="RawText" FontFamily="Consolas" FontSize="11.5" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"
ToolTip="레거시 XML 에 이 글자 그대로 들어갑니다"/>
</DockPanel>
</Expander>
</StackPanel>
</Border>
@@ -34,39 +63,67 @@
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 데이터소스 -->
<!-- 데이터소스 -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"/>
<TextBlock DockPanel.Dock="Top" Text="① 가져올 데이터소스" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/>
<ListBox x:Name="TableList" SelectionChanged="OnFormChanged" FontSize="12.5"/>
<!-- 도움말은 라벨 바로 아래다. Bottom 에 두면 목록이 열을 채워
설명이 목록에서 한참 떨어진 빈 공간에 떠 있었다. -->
<TextBlock DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,6"
Foreground="{DynamicResource B.Muted}"
Text="이 서식에 놓아 둔 조회 상자입니다 — 목록에서만 고릅니다."/>
<ListBox x:Name="TableList" SelectionChanged="OnTableChanged" FontSize="12.5"/>
</DockPanel>
<!-- 나머지 조각 -->
<StackPanel Grid.Column="2">
<TextBlock Text="형태" Margin="0,0,0,4"/>
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged"
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다.">
<ComboBoxItem Content="Select — 필터로 행을 고름"/>
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
</ComboBox>
<!-- ② 컬럼 + ③ 접힌 행 고르기 -->
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" Text="② 가져올 컬럼" FontWeight="SemiBold" Margin="0,0,0,4"/>
<TextBox x:Name="FieldBox" DockPanel.Dock="Top" TextChanged="OnFormChanged"/>
<TextBlock x:Name="FieldHint" DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource B.Muted}"/>
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/>
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/>
<!--
③ 은 접어 둔다. 조건을 쓴 실사용 배선이 0건이고 행 번호는 98%가 0 이라,
첫 방문자가 평생 안 쓸 개념을 먼저 보게 할 이유가 없다.
기본값이 아닐 때만 머리에 칩을 띄워 접혀 있어도 숨지 않게 한다.
-->
<Expander x:Name="RowExpander" DockPanel.Dock="Top" Margin="0,14,0,0">
<Expander.Header>
<StackPanel Orientation="Horizontal">
<TextBlock Text="③ 여러 행 중에서 고르기"/>
<Border x:Name="RowChip" Margin="8,0,0,0" Padding="6,1" CornerRadius="3"
Background="{DynamicResource B.Chip}" Visibility="Collapsed">
<TextBlock x:Name="RowChipText" FontSize="10.5"
Foreground="{DynamicResource B.Muted}"/>
</Border>
</StackPanel>
</Expander.Header>
<StackPanel Margin="0,8,0,0">
<TextBlock Text="몇 번째 행" Margin="0,0,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged"/>
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{DynamicResource B.Muted}"
Text="첫 행이 0 입니다. 1 은 두 번째 행입니다. 반각 숫자만 됩니다."/>
<TextBlock Text="행 번호" Margin="0,10,0,4"/>
<TextBox x:Name="RowBox" TextChanged="OnFormChanged" ToolTip="0부터 셉니다"/>
<TextBlock Text="필드" Margin="0,10,0,4"/>
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged"
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/>
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
Foreground="{DynamicResource B.Muted}"
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/>
</StackPanel>
<!--
조건은 저장된 값이 조건 형태일 때만 보인다. 새로 만들 때는 아예 안 보인다 —
실사용에서 조건을 쓴 배선이 0건이고, 이 문법은 SQL WHERE 가 아니라
이미 받아 온 표를 거르는 식이라 NVL·TO_CHAR·|| 가 통하지 않는다.
-->
<StackPanel x:Name="FilterArea" Visibility="Collapsed">
<TextBlock Text="행 조건" Margin="0,12,0,4"/>
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"/>
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Foreground="{DynamicResource B.Muted}"
Text="이 배선은 조건으로 행을 고르게 저장돼 있습니다. SQL 의 WHERE 가 아니라 받아 온 표를 거르는 식입니다 — 되는 예 CODCOD='A' · 안 되는 예 NVL, TO_CHAR, ||"/>
<Button x:Name="DropFilterButton" Content="조건을 버리고 순서대로 고르기"
Margin="0,8,0,0" Padding="10,3" Click="OnDropFilter"/>
</StackPanel>
</StackPanel>
</Expander>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -6,30 +6,74 @@ using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
/// <summary>
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다.
/// 조회 결과의 한 칸을 컨트롤에 채우는 설정 — 문법 골격을 코드가 조립하고 저장 전에 검사한다.
///
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%),
/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다.
///
/// <b>새 배선은 Rows 형이다.</b> 전에는 레거시 편집기와 같은 Select 형으로 시작했는데,
/// 그러면 첫 화면이 굳이 <b>조건을 요구하는 어려운 쪽</b>이 된다.
/// 동작은 같다 — 조건이 비면 <c>Select("")(0)</c> 과 <c>Rows(0)</c> 이 같은 행을 가리키고,
/// 해석기 자신이 그 등가를 코드로 인정한다(DataTableFieldPicker 는 조건이 비어 있으면
/// 필터 경로를 아예 타지 않는다). 실사용에서 조건을 쓴 배선은 <b>0건</b>이다.
///
/// <b>기존 값의 형태는 그대로 유지한다</b> — 저장된 원문을 우리가 바꾸지 않는 것이
/// XML 왕복 무손실의 조건이다. Select→Rows 전환은 조건을 버리는 일이라
/// 명시적 버튼으로만 하고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
/// </summary>
public partial class DataTableFieldDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList<string> tables;
/// <summary>
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름. 없으면 null(오프라인·미접속).
///
/// <b>여기가 이 창의 유일한 작업 포기 지점이었다.</b> 전에는 "필드 후보는 쿼리를 실행해야
/// 알 수 있어 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 창 밖으로 보냈는데,
/// 이 창은 모달이라 밖이 잠겨 있다 — 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
/// 컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로 그 문구는
/// 퇴행을 설명하는 문장이었다.
///
/// 실행 자체는 이 창이 하지 않는다 — 호출부가 이미 만들어 둔 러너를 함수로 감싸 넘긴다.
/// 그래야 접속·캐시·실패 처리가 한 군데(MDataTableRunner)에만 있다.
/// </summary>
private readonly Func<string, IReadOnlyList<string>?>? columnsOf;
/// <summary>
/// 데이터소스별 컬럼 캐시 — 이 창이 사는 동안 <b>데이터소스마다 한 번만</b> 묻는다.
/// Refresh 는 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다.
/// </summary>
private readonly Dictionary<string, IReadOnlyList<string>?> columnCache = new(StringComparer.Ordinal);
private bool loading = true;
/// <summary>연 값의 형태 — 저장할 때 그대로 되돌려 쓴다(원문을 우리가 바꾸지 않는다)</summary>
private DataTableFieldForm form = DataTableFieldForm.Rows;
#endregion
#region Properties
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary>
/// <summary>적용 결과 — 취소면 null, '가져오지 않음'이면 빈 문자열</summary>
public string? Result { get; private set; }
#endregion
#region Constructors
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables)
/// <param name="current">지금 저장된 값 — 없으면 새 배선</param>
/// <param name="dataTables">이 서식의 데이터소스 이름</param>
/// <param name="ownerLabel">대상 컨트롤 이름 — 제목에 넣어 "지금 어느 칸 차례인지"를 알린다</param>
/// <param name="columnsOf">데이터소스의 컬럼 이름을 주는 함수(없으면 직접 입력만)</param>
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables,
string? ownerLabel = null, Func<string, IReadOnlyList<string>?>? columnsOf = null)
{
InitializeComponent();
tables = dataTables;
this.columnsOf = columnsOf;
if (ownerLabel is { Length: > 0 })
{
// 모달이라 인스펙터가 가려진다 — 여러 칸을 연달아 채울 때 어느 칸 차례인지 알 수 없었다
Title = $"값 가져오기 — {ownerLabel}";
}
foreach (var name in tables)
{
@@ -40,16 +84,21 @@ public partial class DataTableFieldDialogView : Window
var spec = DataTableFieldSpec.Parse(current);
if (spec is not null)
{
FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0;
form = spec.Form;
TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
FilterBox.Text = spec.Filter;
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
FieldBox.Text = spec.Field;
// 기본값이 아닌 것이 접혀 있으면 숨는다 — 그때만 펼친 채로 연다
if (spec.RowIndex != 0 || (form == DataTableFieldForm.Select && spec.Filter.Length > 0))
{
RowExpander.IsExpanded = true;
}
}
else
{
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다
FormBox.SelectedIndex = 0;
// 새 배선 — 조건 없이 첫 행. 첫 화면에 평생 안 쓸 개념을 두지 않는다.
form = DataTableFieldForm.Rows;
RowBox.Text = "0";
if (tables.Count == 1)
{
@@ -59,47 +108,134 @@ public partial class DataTableFieldDialogView : Window
loading = false;
Refresh();
// 첫 포커스 — 데이터소스가 이미 정해졌으면 다음 할 일(컬럼)로 보낸다.
// 이 호출이 없으면 선언 순서상 첫 Tab 정지점이 파괴적 버튼이었다.
Loaded += (_, _) =>
{
if (TableList.SelectedItem is null && TableList.Items.Count > 0)
{
TableList.Focus();
}
else
{
FieldBox.Focus();
}
};
}
#endregion
#region Methods
/// <summary>
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135).
/// 현재 입력을 명세로 옮긴다.
///
/// <b>행 번호를 조용히 0 으로 바꾸지 않는다.</b> 전에는 파싱 실패가 그대로 0 이 됐는데,
/// 그러면 전각 '1' 을 넣은 배선이 아무 말 없이 첫 행을 가리킨다(실사용에 그런 값이 실재한다).
/// 잘못된 입력은 <see cref="Refresh"/> 가 이유를 말하고 확인을 막는다.
/// </summary>
private DataTableFieldSpec CurrentSpec()
private DataTableFieldSpec CurrentSpec() => new()
{
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
? parsed
: 0;
return new DataTableFieldSpec
Form = form,
TableName = TableList.SelectedItem as string ?? string.Empty,
Filter = form == DataTableFieldForm.Select ? FilterBox.Text : string.Empty,
RowIndex = ParsedRow() ?? 0,
Field = FieldBox.Text.Trim(),
};
/// <summary>행 번호 — 반각 숫자로 읽히지 않으면 null</summary>
private int? ParsedRow()
{
var text = RowBox.Text.Trim();
if (text.Length == 0)
{
Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select,
TableName = TableList.SelectedItem as string ?? string.Empty,
Filter = FilterBox.Text,
RowIndex = row,
Field = FieldBox.Text.Trim(),
};
return 0; // 빈 값은 첫 행이다(레거시도 빈 값을 0 으로 본다)
}
return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
}
private void Refresh()
{
// 조건 칸은 저장된 값이 조건 형태일 때만 보인다
FilterArea.Visibility = form == DataTableFieldForm.Select ? Visibility.Visible : Visibility.Collapsed;
var spec = CurrentSpec();
var rowsForm = spec.Form == DataTableFieldForm.Rows;
RawText.Text = spec.Compose();
UpdateRowChip(spec);
UpdateFieldHint(spec.TableName);
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된
FilterBox.IsEnabled = !rowsForm;
FilterLabel.Opacity = rowsForm ? 0.45 : 1;
FilterBox.Opacity = rowsForm ? 0.45 : 1;
// 전각 숫자처럼 읽을 수 없는 행 번호는 <b>먼저</b> 말한다 — 조용히 0 으로 바꾸지 않는
var problem = ParsedRow() is null
? "몇 번째 행: 반각 숫자만 됩니다"
// 고를 데이터소스가 아예 없으면 '고르세요' 는 따를 수 없는 지시다 — 다음 행동을 말한다
: tables.Count == 0
? "이 서식에 데이터소스가 없습니다 — 팔레트에서 먼저 놓고 쿼리를 지정하세요"
: spec.Validate(tables);
PreviewText.Text = spec.Compose();
var problem = spec.Validate(tables);
PlainText.Text = problem is null ? PlainSentence(spec) : "아직 가져올 값이 정해지지 않았습니다.";
ProblemText.Text = problem ?? string.Empty;
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
OkButton.IsEnabled = problem is null;
}
/// <summary>사람 말 한 줄 — 이 창이 무엇을 하기로 했는지</summary>
private static string PlainSentence(DataTableFieldSpec spec)
{
var where = spec.RowIndex == 0 ? "첫 행" : $"{spec.RowIndex + 1}번째 행";
var scope = spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0
? "조건을 통과한 행 중 " + where
: where;
return $"서식을 열면 — {spec.TableName} 조회 결과의 {scope}에서 "
+ $"{spec.Field} 값을 가져와 이 칸에 넣습니다.";
}
/// <summary>접힌 ③ 의 머리 칩 — 기본값이 아니면 접혀 있어도 드러낸다</summary>
private void UpdateRowChip(DataTableFieldSpec spec)
{
var parts = new List<string>();
if (spec.RowIndex != 0)
{
parts.Add($"{spec.RowIndex + 1}번째 행");
}
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
{
parts.Add("조건 있음");
}
RowChipText.Text = string.Join(" · ", parts);
RowChip.Visibility = parts.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
/// <summary>
/// 컬럼 칸 아래 안내 — 실제 컬럼 목록을 얻을 수 있으면 그것을 보여 준다.
/// 못 얻으면 <b>왜 못 얻는지</b>를 말한다(전에는 "목록으로 제시하지 않습니다"로 끝났다).
/// </summary>
private void UpdateFieldHint(string tableName)
{
if (tableName.Length == 0)
{
FieldHint.Text = "데이터소스를 먼저 고르면 그 조회 결과의 컬럼 이름을 보여 줍니다.";
return;
}
if (!columnCache.TryGetValue(tableName, out var columns))
{
columns = columnsOf?.Invoke(tableName);
columnCache[tableName] = columns;
}
if (columns is { Count: > 0 })
{
FieldHint.Text = $"{tableName} 의 컬럼 {columns.Count}개 — "
+ string.Join(", ", columns.Take(12))
+ (columns.Count > 12 ? " …" : string.Empty);
return;
}
// 못 읽었을 때도 <b>왜</b>인지 말한다. 전에는 "목록으로 제시하지 않습니다" 로 끝나
// 사용자가 무엇을 하면 되는지 알 수 없었다. 문구는 짧게 — 여기가 도움말 자리다.
FieldHint.Text = "컬럼 목록을 읽지 못했습니다(DB 미접속·쿼리 실패) — 직접 입력하세요."
+ " 조회 결과 표의 머리글 이름이고 대개 전부 대문자입니다(예: ALB, ACPDTE).";
}
private void OnTableChanged(object sender, SelectionChangedEventArgs e) => OnFormChanged(sender, e);
private void OnFormChanged(object sender, RoutedEventArgs e)
{
if (!loading)
@@ -108,8 +244,42 @@ public partial class DataTableFieldDialogView : Window
}
}
/// <summary>
/// 조건을 버리고 순서대로 고르기 — <b>되돌릴 수 없어</b> 버려지는 조건을 먼저 보여 준다.
///
/// 전에는 형태 콤보를 바꾸는 것만으로 조건이 조립에서 조용히 사라졌고(Rows 조립은 Filter 를
/// 아예 쓰지 않는다) 다시 열면 Rows 해석에 조건 항목이 없어 영구히 없어졌다.
/// 화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다.
/// </summary>
private void OnDropFilter(object sender, RoutedEventArgs e)
{
var filter = FilterBox.Text;
if (filter.Length > 0 && !Services.DialogService.Confirm("조건 버리기",
"이 배선의 행 조건을 지우고 순서대로 고르게 바꿉니다.",
$"지워지는 조건: {filter}\n확인을 누르면 되돌릴 수 없습니다.",
destructive: true, owner: this))
{
return;
}
form = DataTableFieldForm.Rows;
FilterBox.Text = string.Empty;
Refresh();
}
private void OnCopyRaw(object sender, RoutedEventArgs e)
{
if (RawText.Text is { Length: > 0 } raw)
{
Clipboard.SetText(raw);
}
}
private void OnApply(object sender, RoutedEventArgs e)
{
if (ParsedRow() is null)
{
return;
}
var spec = CurrentSpec();
if (spec.Validate(tables) is not null)
{
@@ -17,6 +17,76 @@
<bh:CanvasKeyboardBehavior/>
</b:Interaction.Behaviors>
<!--
캔버스 우클릭 메뉴 — 전에는 앱 전체에 우클릭 메뉴가 하나도 없었다.
배치·정렬·같은 크기·간격·앞뒤·그룹이 전부 로고 아래 4단 메뉴 안에만 있어서,
선택한 것을 정렬하려면 캔버스에서 손을 떼고 화면 좌상단까지 마우스를 옮겨야 했다.
단축키(Alt+A/D/W/S/H/V …)와 같은 명령을 같은 자리에서 보여 준다 —
단축키를 모르는 사람도 여기서 배운다.
-->
<UserControl.ContextMenu>
<ContextMenu>
<!--
선택 확장이 <b>맨 위</b>다. 아래 정렬·간격 명령이 전제하는 '올바른 선택'을
만드는 수단이고, 그것 없이는 매번 "마퀴로 끌고 딸려온 것 빼기"가 앞에 붙는다.
실측(787건): 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개다.
-->
<MenuItem Header="같은 행 선택" InputGestureText="Alt+1"
Command="{Binding SelectSameCommand}" CommandParameter="Row"/>
<MenuItem Header="같은 열 선택" InputGestureText="Alt+2"
Command="{Binding SelectSameCommand}" CommandParameter="Column"/>
<MenuItem Header="같은 종류 선택" InputGestureText="Alt+3"
Command="{Binding SelectSameCommand}" CommandParameter="Type"/>
<MenuItem Header="같은 크기 선택" InputGestureText="Alt+4"
Command="{Binding SelectSameCommand}" CommandParameter="Size"/>
<Separator/>
<MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
Command="{Binding AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
Command="{Binding AlignCommand}" CommandParameter="Right"/>
<MenuItem Header="위 맞춤" InputGestureText="Alt+W"
Command="{Binding AlignCommand}" CommandParameter="Top"/>
<MenuItem Header="아래 맞춤" InputGestureText="Alt+S"
Command="{Binding AlignCommand}" CommandParameter="Bottom"/>
<MenuItem Header="가로 가운데" InputGestureText="Alt+H"
Command="{Binding AlignCommand}" CommandParameter="CenterH"/>
<MenuItem Header="세로 가운데" InputGestureText="Alt+V"
Command="{Binding AlignCommand}" CommandParameter="CenterV"/>
<Separator/>
<MenuItem Header="가로 간격 균등" InputGestureText="Alt+Shift+H"
Command="{Binding SpacingCommand}" CommandParameter="HorizEqual"/>
<MenuItem Header="세로 간격 균등" InputGestureText="Alt+Shift+V"
Command="{Binding SpacingCommand}" CommandParameter="VertEqual"/>
<MenuItem Header="세로 간격 넓게" InputGestureText="Alt+]"
Command="{Binding SpacingCommand}" CommandParameter="VertIncrease"/>
<MenuItem Header="세로 간격 좁게" InputGestureText="Alt+["
Command="{Binding SpacingCommand}" CommandParameter="VertDecrease"/>
<Separator/>
<MenuItem Header="너비 같게" InputGestureText="Alt+Shift+W"
Command="{Binding SizeToControlCommand}" CommandParameter="Width"/>
<MenuItem Header="높이 같게" InputGestureText="Alt+Shift+S"
Command="{Binding SizeToControlCommand}" CommandParameter="Height"/>
<Separator/>
<MenuItem Header="그룹으로 묶기" InputGestureText="Ctrl+G"
Command="{Binding GroupCommand}"/>
<MenuItem Header="그룹 풀기" InputGestureText="Ctrl+Shift+G"
Command="{Binding UngroupCommand}"/>
<Separator/>
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
<Separator/>
<!--
'잠그기'로 이름을 한정한다 — 해제는 여기서 못 한다. 히트 판정이 잠긴 것을
후보에서 빼므로 잠긴 컨트롤에는 우클릭조차 닿지 않는다. 해제는 레이어 패널에서.
-->
<MenuItem Header="선택 잠그기" InputGestureText="Ctrl+L" Command="{Binding LockCommand}"/>
<MenuItem Header="선택 숨기기" InputGestureText="Ctrl+Shift+H" Command="{Binding HideCommand}"/>
<Separator/>
<MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/>
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
</ContextMenu>
</UserControl.ContextMenu>
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
<ScrollViewer x:Name="Scroll"
HorizontalScrollBarVisibility="Auto"
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="12">
Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
@@ -23,7 +23,10 @@
<DockPanel Margin="0,10,0,0">
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/>
<!-- 강조는 <b>고른 것만</b> 바꾸는 쪽이다. '전체에 적용'은 문서 전체를 한 번에 바꾸므로
눈이 먼저 가는 자리에 두지 않는다 — 되돌리기가 있어도 되돌릴 생각을 하려면 먼저 알아채야 한다. -->
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"
Style="{StaticResource Primary}"/>
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
</DockPanel>
</StackPanel>
+169 -13
View File
@@ -11,6 +11,7 @@
<UserControl.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:ChoiceLabelConverter x:Key="ChoiceLabel"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<!--
@@ -174,7 +175,8 @@
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
-->
<Style x:Key="RowLabel" TargetType="TextBlock">
<Setter Property="Width" Value="72"/>
<!-- 위 주석이 92 를 논증해 놓고 값은 72 였다 — 그래서 '액션 대상 컨…' 처럼 잘렸다 -->
<Setter Property="Width" Value="92"/>
<Setter Property="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="FontSize" Value="12"/>
@@ -299,6 +301,18 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger>
<!--
갈림(다중선택에서 값이 서로 다름) — 표시가 없으면 '꺼짐'과 픽셀 단위로 같다.
그러면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 만들어도
(WPF 는 불확정에서 false 로 간다) 사용자는 무엇이 일어났는지 알 수 없다.
동작 자체는 ToggleRowViewModel.IsOn 이 '갈림에서 누르면 켠다'로 고쳤고,
여기서는 <b>갈렸다는 사실</b>을 보이게 한다. SwitchToggle(:105-108)과 같은 표현.
-->
<Trigger Property="IsChecked" Value="{x:Null}">
<Setter TargetName="bd" Property="Opacity" Value="0.55"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Line2}"/>
</Trigger>
</ControlTemplate.Triggers>
</ControlTemplate>
</Setter.Value>
@@ -378,6 +392,38 @@
</Button>
</DataTemplate>
<!--
글꼴 이름 — 고르기도 되고 쓰기도 된다(IsEditable).
목록에 가두면 이 PC 에 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고,
칸을 건드리는 순간 사라진다. 병원 PC 와 설치 글꼴이 다른 것이 정상이다.
UpdateSourceTrigger=LostFocus 인 이유: 커밋 한 번이 문서 전체 딥클론 1회다.
글자마다 커밋하면 "맑은 고딕" 여섯 자에 스냅샷 여섯 개가 쌓인다.
-->
<DataTemplate DataType="{x:Type ins:FontFamilyRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<!-- 이 PC 에 없는 글꼴이면 알린다 — 값은 그대로 두고 '화면이 병원과 다르다'만 말한다 -->
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
ToolTip="{Binding MissingHint}"
Visibility="{Binding IsMissingFont, Converter={StaticResource BoolToVisibility}}"/>
<Grid>
<ComboBox Style="{StaticResource RowCombo}" IsEditable="True" IsTextSearchEnabled="True"
ItemsSource="{Binding Families}"
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}">
<ComboBox.ItemTemplate>
<DataTemplate>
<!-- 이름을 그 글꼴로 그린다 — 이름만으로는 어떤 모양인지 알 수 없다 -->
<TextBlock Text="{Binding}" FontFamily="{Binding}" FontSize="13"/>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid>
</DockPanel>
</DataTemplate>
<DataTemplate DataType="{x:Type ins:TextRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
@@ -425,6 +471,15 @@
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
<!--
갈린 값이면 9칸이 전부 꺼져 '아직 아무것도 안 고른 격자'와 똑같이 보인다.
다른 편집기는 MixedHintOverlay 로 알리는데 이 격자만 알릴 자리가 없었다 —
칸 위가 아니라 격자 오른쪽에 붙여 어느 칸도 가리지 않게 한다.
-->
<TextBlock DockPanel.Dock="Right" Text="여러 값" FontStyle="Italic" FontSize="11"
VerticalAlignment="Top" Margin="6,6,0,0"
Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsMixed, Converter={StaticResource BoolToVisibility}}"/>
<ItemsControl ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
@@ -533,7 +588,28 @@
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<Grid>
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}"/>
<!--
빈 항목은 '여러 값' 자리다(ChoiceRowViewModel.EnsureMixedPlaceholder).
그냥 두면 목록에 빈 줄로 보여 무엇인지 알 수 없다.
-->
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding Converter={StaticResource ChoiceLabel}}">
<TextBlock.Style>
<Style TargetType="TextBlock">
<Style.Triggers>
<DataTrigger Binding="{Binding}" Value="">
<Setter Property="FontStyle" Value="Italic"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid>
</DockPanel>
@@ -547,10 +623,52 @@
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
<!--
카탈로그에 없는 이름 — 글꼴 행과 같은 규칙이다. 값은 건드리지 않고 표식만 낸다.
인라인으로 직접 치는 것이 이 행의 주 경로인데 검증이 하나도 없어서,
한 글자 틀려도 조용히 커밋되고 미리보기에서 <b>빈칸</b>으로만 드러났다 —
빈칸의 원인이 오타인지 자료가 없어서인지 가릴 수 없었다.
-->
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
ToolTip="{Binding UnknownTagHint}"
Visibility="{Binding IsUnknownTag, Converter={StaticResource BoolToVisibility}}"/>
<Grid>
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
ToolTip="직접 입력하거나 아래 제안을 누르세요"/>
<TextBox x:Name="TagInput" Style="{StaticResource RowInput}"
bh:InspectorFieldBehavior.CommitOnEnter="True"
bh:TagCompletionBehavior.Enabled="True"
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
ToolTip="치면 목록이 뜹니다 — ↑↓ 이동, Enter 확정, Esc 취소"/>
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
<!--
인라인 완성 — 이 행에서 모달 없이 태그를 고르는 주 경로.
StaysOpen=True 가 필수다: False 면 팝업이 마우스를 캡처해 항목 클릭을
'바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다 —
QueryEditorWindow 의 CompletionPopup 에서 같은 것을 이미 겪었다).
AllowsTransparency 를 켜지 않는다 — 레이어드 창이 되면 ClearType 이 꺼져
13px 한글이 뭉개진다(진단 cleartype 의 D 표본).
-->
<Popup IsOpen="{Binding ShowCompletions, Mode=OneWay}" PlacementTarget="{Binding ElementName=TagInput}"
Placement="Bottom" StaysOpen="True" Focusable="False">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
BorderThickness="1" CornerRadius="6" Padding="2"
MinWidth="{Binding ActualWidth, ElementName=TagInput}"
RenderOptions.ClearTypeHint="Enabled">
<ItemsControl ItemsSource="{Binding Completions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Border Height="24" Padding="7,0" CornerRadius="4"
Background="Transparent"
MouseLeftButtonDown="OnCompletionClicked">
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="11.5"
VerticalAlignment="Center"
Foreground="{DynamicResource B.Ink}"/>
</Border>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</Border>
</Popup>
</Grid>
</DockPanel>
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
@@ -577,8 +695,15 @@
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
<DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<!--
이 자리는 값이 아니라 <b>설명</b>이다 — 자르면 결론이 사라진다.
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
지시에 해당하는 뒷부분이 잘려 나가 무엇을 하라는 건지 알 수 없었다.
접어서 다 보여 주고, 그래도 좁으면 툴팁이 받는다.
-->
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
VerticalAlignment="Center" TextWrapping="Wrap"
ToolTip="{Binding ValueText}"/>
</DockPanel>
</DataTemplate>
@@ -740,7 +865,10 @@
HorizontalOffset="-176" VerticalOffset="4">
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line2}"
BorderThickness="1" CornerRadius="8" Padding="10" Effect="{DynamicResource B.FloatShadow}">
<StackPanel Width="212">
<!-- Effect 와 레이어드 팝업이 각각 중간 서피스를 만들어 ClearType 을 끈다.
힌트는 Effect 가 걸린 바깥 Border 에 걸면 듣지 않으므로, 안쪽 불투명 배경에 건다. -->
<StackPanel Width="212" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<TextBlock Text="배경" FontSize="11" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,5"/>
<ItemsControl ItemsSource="{Binding BackgroundSwatches}"
ItemTemplate="{StaticResource ColorSwatchTemplate}">
@@ -786,11 +914,11 @@
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center"
IsTabStop="False" Focusable="False"
Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/>
<Button DockPanel.Dock="Right" Width="24" Height="24" Padding="0"
Style="{StaticResource Subtle}" ToolTip="이 컨트롤에 대한 명령"
AutomationProperties.Name="더보기"
Visibility="{Binding IsSingleSelection, Converter={StaticResource BoolToVisibility}}"
Content="{Binding Source=ellipsis-vertical, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/>
<!--
⋮ 버튼을 지웠다. Command·Click·ContextMenu 가 <b>전부 없어</b> 눌러도 아무 일이 없었는데,
툴팁은 '이 컨트롤에 대한 명령' 이라 '단일 선택일 때만 되는 뭔가가 있다'고 오해시켰다.
없는 버튼보다 죽은 버튼이 나쁘다. 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 다 있다.
-->
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
@@ -873,6 +1001,34 @@
Content="{Binding Source=distribute-v, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
</StackPanel>
<!--
간격을 숫자로 — 여기까지 간격을 정하는 길은 4px 씩 넓게/좁게뿐이었다.
20px 를 32px 로 바꾸려면 3번, 메뉴로 하면 12왕복이었다.
드래그 스냅은 형제 간격을 정확히 계산해 표식까지 그리는데(SnapSolver)
명령 경로에는 그 값을 넣을 칸이 없었다.
균등(3개 이상)과 달리 <b>둘</b>부터 뜻이 있다 — 라벨과 입력칸 한 쌍이 가장 흔하다.
값이 갈리면 빈 칸이다. 하나로 보여 주면 이미 적용된 것처럼 읽힌다.
-->
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,0,10,6"
Visibility="{Binding CanSpaceExactly, Converter={StaticResource BoolToVisibility}}">
<TextBlock Text="간격값" VerticalAlignment="Center" FontSize="11.5" Margin="0,0,8,0"
Foreground="{DynamicResource B.Muted}"/>
<TextBlock Text="세로" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource B.Muted}" Margin="0,0,4,0"/>
<TextBox Width="46" Style="{StaticResource RowInput}"
bh:InspectorFieldBehavior.CommitOnEnter="True"
Text="{Binding VerticalGapText, UpdateSourceTrigger=LostFocus}"
AutomationProperties.Name="세로 간격 픽셀"
ToolTip="숫자를 넣고 Enter — 첫 항목은 움직이지 않는다"/>
<TextBlock Text="가로" VerticalAlignment="Center" FontSize="11"
Foreground="{DynamicResource B.Muted}" Margin="10,0,4,0"/>
<TextBox Width="46" Style="{StaticResource RowInput}"
bh:InspectorFieldBehavior.CommitOnEnter="True"
Text="{Binding HorizontalGapText, UpdateSourceTrigger=LostFocus}"
AutomationProperties.Name="가로 간격 픽셀"
ToolTip="숫자를 넣고 Enter — 첫 항목은 움직이지 않는다"/>
</StackPanel>
<!--
정렬 기준 — 여럿을 골라 놓고도 '서로 맞추기'와 '종이에 맞추기'는 다른 작업이다.
왼쪽 문구는 선택에 따라 바뀐다: 여럿이면 '선택 영역', 하나면 '부모'(또는 '페이지').
@@ -889,8 +1045,8 @@
ToolTip="{Binding PageScopeTip}"/>
</StackPanel>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
<ScrollViewer x:Name="RowScroll" VerticalScrollBarVisibility="Auto">
<ItemsControl x:Name="RowHost" ItemsSource="{Binding Rows}" Margin="10,0,10,10">
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
@@ -1,12 +1,194 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Controls.Primitives;
using System.Windows.Input;
using System.Windows.Media;
using SheetMe.Designer.ViewModels.Inspector;
namespace SheetMe.Designer.Views;
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
/// <summary>
/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.
///
/// <b>선택이 바뀌어도 손이 이어지게 한다.</b> 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서,
/// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건
/// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
/// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
///
/// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라
/// 이번에는 재구성을 그대로 두고 <b>포커스와 스크롤만 되돌린다</b>.
/// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
/// </summary>
public partial class InspectorView : UserControl
{
#region Member Fields
/// <summary>재구성 직전에 포커스가 있던 행의 라벨</summary>
private string? focusedLabel;
private double savedOffset;
#endregion
#region Constructors
public InspectorView()
{
InitializeComponent();
DataContextChanged += OnDataContextChanged;
}
#endregion
#region Methods
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
{
if (e.OldValue is InspectorViewModel old)
{
old.Rebuilding -= OnRebuilding;
old.Rebuilt -= OnRebuilt;
}
if (e.NewValue is InspectorViewModel fresh)
{
fresh.Rebuilding += OnRebuilding;
fresh.Rebuilt += OnRebuilt;
}
}
private void OnRebuilding()
{
savedOffset = RowScroll.VerticalOffset;
focusedLabel = null;
if (Keyboard.FocusedElement is DependencyObject focused && IsDescendantOfRows(focused))
{
focusedLabel = RowLabelOf(focused);
}
}
private void OnRebuilt()
{
// 컨테이너가 아직 없다 — 레이아웃이 한 번 돌아야 행이 생긴다.
// Loaded 나 즉시 호출로는 늘 빈 트리를 뒤지게 된다(이 저장소에서 같은 함정을 두 번 밟았다).
Dispatcher.BeginInvoke(new Action(Restore), System.Windows.Threading.DispatcherPriority.Loaded);
}
private void Restore()
{
RowScroll.ScrollToVerticalOffset(savedOffset);
if (focusedLabel is null)
{
return;
}
var target = FindRowByLabel(focusedLabel);
focusedLabel = null;
// 포커스를 강제로 옮기지 않는다 — 사용자가 그 사이에 다른 곳을 눌렀으면 그쪽이 옳다
if (target is not null && Keyboard.FocusedElement is not TextBoxBase)
{
target.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
}
/// <summary>
/// 완성 목록의 한 줄을 눌렀다.
///
/// 팝업은 StaysOpen=True 다 — False 로 두면 팝업이 마우스를 캡처해 이 클릭을
/// '바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다).
/// 그래서 클릭을 직접 받아 값으로 확정하고 코드로 닫는다.
/// </summary>
private void OnCompletionClicked(object sender, MouseButtonEventArgs e)
{
if (sender is not FrameworkElement { DataContext: string picked }
|| picked.Length == 0
|| FindRowOwner(sender) is not PropertyRowViewModel row)
{
return;
}
if (row is ViewModels.Inspector.TagPickerRowViewModel tagRow)
{
tagRow.EndTyping();
}
row.ValueText = picked;
e.Handled = true;
}
/// <summary>이 요소가 속한 행 VM — 팝업 안이라 시각 트리가 본체와 끊겨 논리 부모로도 올라간다</summary>
private static PropertyRowViewModel? FindRowOwner(object node)
{
var current = node as DependencyObject;
for (var i = 0; i < 40 && current is not null; i++)
{
if (current is FrameworkElement { DataContext: PropertyRowViewModel row })
{
return row;
}
current = VisualParentOf(current);
}
return null;
}
/// <summary>Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다</summary>
public void FocusFirstRow()
{
if (RowHost.Items.Count == 0)
{
return;
}
RowHost.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
}
private bool IsDescendantOfRows(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (ReferenceEquals(node, RowHost))
{
return true;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return false;
}
node = parent;
}
return false;
}
/// <summary>이 요소가 속한 행의 라벨 — 행 VM 을 DataContext 로 갖는 조상을 찾는다</summary>
private static string? RowLabelOf(DependencyObject node)
{
for (var i = 0; i < 40; i++)
{
if (node is FrameworkElement { DataContext: PropertyRowViewModel row })
{
return row.Label;
}
var parent = VisualParentOf(node);
if (parent is null)
{
return null;
}
node = parent;
}
return null;
}
private FrameworkElement? FindRowByLabel(string label)
{
foreach (var item in RowHost.Items)
{
if (item is not PropertyRowViewModel row || !string.Equals(row.Label, label, StringComparison.Ordinal))
{
continue;
}
if (RowHost.ItemContainerGenerator.ContainerFromItem(row) is FrameworkElement container)
{
return container;
}
}
return null;
}
/// <summary>ContentPresenter 는 시각 부모만 갖는 경우가 있어 논리 부모로도 올라간다</summary>
private static DependencyObject? VisualParentOf(DependencyObject node)
=> node is Visual or System.Windows.Media.Media3D.Visual3D
? VisualTreeHelper.GetParent(node)
: LogicalTreeHelper.GetParent(node);
#endregion
}
+42 -8
View File
@@ -283,13 +283,41 @@
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/>
<!--
컨테이너의 자식 수 — 접혀 있어도 무엇이 딸려 있는지 보여야 한다.
페이지 행과 그룹 폴더에는 이미 개수가 붙는데 컨테이너만 없었다.
중첩까지 세므로 "이걸 지우면 몇 개가 같이 지워지나"가 그대로 읽힌다.
-->
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" FontSize="11"
Margin="4,0,2,0" Text="{Binding LayerChildCountText}"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<!-- 선택 밴드 위에서 B.Muted 는 1.03:1 — 개수가 통째로 사라진다 -->
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
<Setter Property="Foreground" Value="{DynamicResource B.OnSelMuted}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
<!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 -->
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 -->
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16"
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/>
<!--
컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다.
<b>없을 때도 16px 칸은 남긴다.</b> 전에는 셰브론이 Collapsed 로 사라져
폭 16 이 통째로 없어졌는데, 컨테이너 자식 들여쓰기도 정확히 16 이라
<b>부모와 자식의 아이콘이 같은 x 에 놓였다</b> — 목록만 보면 형제로 읽힌다.
계층을 나타내는 유일한 수단인 들여쓰기가 셰브론 폭에 정확히 상쇄되고 있었다.
-->
<Border DockPanel.Dock="Left" Width="16">
<ToggleButton Style="{StaticResource MiniChevron}" Width="16"
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/>
</Border>
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/>
<!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 -->
@@ -321,10 +349,16 @@
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0"
<!--
둘째 줄은 <b>항상</b> 있다 — 없으면 목록에 1줄 행과 2줄 행이 섞여
훑을 때 눈이 매번 자리를 다시 잡아야 한다.
타입명을 앞에 두는 이유: 아이콘만으로 안 갈리는 짝이 실제로 있다
(라벨·마스크입력이 같은 글리프, 리스트박스·체크리스트도 같다).
이름(Id)은 표시명과 다를 때만 뒤에 붙는다 — 외부 참조 키라 계속 보여야 한다.
-->
<TextBlock Text="{Binding LayerSubText}" FontSize="10.5" Margin="0,1,0,0"
TextTrimming="CharacterEllipsis"
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다"
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}">
ToolTip="{Binding LayerSubText}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
@@ -103,6 +103,8 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl
case System.Windows.Input.Key.X when ctrl: designer.CutSelection(); break;
case System.Windows.Input.Key.V when ctrl: designer.Paste(); break;
case System.Windows.Input.Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
// 캔버스와 같은 짝 — 레이어 목록에 포커스가 있을 때도 Ctrl+Shift+Z 는 다시 실행이다
case System.Windows.Input.Key.Z when ctrl && shift: designer.Undo.Redo(); break;
case System.Windows.Input.Key.Z when ctrl: designer.Undo.Undo(); break;
case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break;
case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break;
@@ -4,18 +4,18 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="타입으로 거르기" Width="380" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
Style="{StaticResource ThemedDialogWindow}">
<Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
</Window.Resources>
<DockPanel Margin="12">
<DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
+101 -24
View File
@@ -17,6 +17,7 @@
WindowStyle="None" WindowStartupLocation="CenterScreen"
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing"
PreviewKeyDown="OnWindowPreviewKeyDown"
Style="{StaticResource ThemedWindow}">
<!--
@@ -24,12 +25,32 @@
처리하며, 여기 올리면 인스펙터·검색 상자 등 TextBox 의 표준 편집 키와 충돌한다.
-->
<Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewFileCommand}"/>
<KeyBinding Key="O" Modifiers="Control" Command="{Binding OpenFileCommand}"/>
<!-- Ctrl+O 는 코드비하인드에서 서식 목록으로 보낸다(파일 대화상자가 아니다) -->
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
<KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
<!--
줌 — 커맨드 넷은 처음부터 있었고 <b>키만 없었다</b>. 8pt 글자를 확인하려 200% 로 올렸다가
전체 배치를 보려면 캔버스에서 손을 떼고 화면 맨 아래 가운데 플로팅 바의 배율 버튼을 눌러
팝업을 열고 '화면 맞춤'을 골라야 했다(2단계 + 마우스 왕복). 확대·축소조차 Ctrl+휠뿐이라
트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.
반드시 창 InputBindings 여야 한다 — 캔버스 키 처리기는 포커스가 TextBox/ComboBox 면
통째로 비켜서므로, 인스펙터 값을 고친 직후에는 줌 키가 죽는다.
-->
<KeyBinding Key="D0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
<KeyBinding Key="NumPad0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
<KeyBinding Key="D1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
<KeyBinding Key="NumPad1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
<KeyBinding Key="OemPlus" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
<KeyBinding Key="Add" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
<KeyBinding Key="OemMinus" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
<KeyBinding Key="Subtract" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
<!-- 좌우 패널 접기(Ctrl+\)는 코드비하인드에서 다룬다 — 열 너비를 직접 만지는 일이라
뷰모델이 알 필요가 없다(F4 와 같은 규범) -->
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
</Window.InputBindings>
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
@@ -151,18 +172,31 @@
Content="{Binding Source=chevron-down, Converter={StaticResource IconName}, ConverterParameter=11}"/>
</StackPanel>
</MenuItem.Header>
<!--
파일 경로를 메뉴에서 뺐다 — 열기·저장은 DB 만 쓴다.
운영 서식 1,271건이 전부 DB 에 있고, 파일로 저장한 XML 은 EMR 이 읽지 않는다.
'DB에서 열기' 대화상자도 지웠다 — 좌측 '서식 목록' 탭이 같은 조회를 상시로 한다
(그 창이 덮고 있던 구멍 넷을 좌측에 먼저 옮겼다: 저장 후 재조회 · 서식명 툴팁 ·
디자인 없음/사용 꺼짐 구분 · Enter 로 열기).
파일 저장·열기 코드는 남아 있다 — 접속 없이 도는 진단(render-smoke,
edit-smoke 직렬화 왕복)이 그 경로를 쓴다. UI 진입점만 없앤 것이다.
-->
<MenuItem Header="파일(_F)">
<MenuItem Header="서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
<MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/>
<Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}" InputGestureText="Ctrl+Shift+S"/>
<Separator/>
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
<Separator/>
<MenuItem Header="JSON 가져오기(_I)..." Command="{Binding ImportJsonCommand}"/>
<MenuItem Header="JSON 내보내기(_E)..." Command="{Binding ExportJsonCommand}"/>
<MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S"
IsEnabled="{Binding CanSaveToDb}"
ToolTip="새 버전을 만듭니다. 지금 버전은 이력으로 남습니다"/>
<!--
수정 — 레거시 '기록지 수정'. 저장과 다른 동작이라 따로 둔다.
저장은 새 버전을 만들어 그것이 활성이 되고, 수정은 지금 쓰이는 버전을
그 자리에서 고친다(SdgKey 유지). E_SctMst 를 안 건드리므로 글자만 고칠 수 있고,
새 컨트롤이 있으면 커맨드가 막는다.
레거시처럼 전산실에만 열어 둔다 — 저장 버튼에는 그런 분기가 없다.
-->
<MenuItem Header="수정(_M) — 지금 버전을 제자리에서" Command="{Binding ModifyInDbCommand}"
IsEnabled="{Binding CanModifyInDb}"
ToolTip="새 버전을 만들지 않고 지금 버전의 글자만 고칩니다 (전산실 전용)"/>
<Separator/>
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
@@ -279,6 +313,14 @@
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="다크/라이트 전환"
Click="OnThemeToggleClick"
Content="{Binding Source=sun, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!--
환자 선택 — 앱 전체에 적용된다. 전에는 미리보기 창 안에만 있어서
쿼리 편집처럼 환자 값이 필요한 다른 화면에서 고를 수가 없었다.
여기서 한 번 고르면 PatientSession 하나를 모든 화면이 본다.
고른 상태는 <b>아이콘 색</b>으로 알린다 — 늘 보이는 자리라 글자를 둘 폭이 없다.
-->
<Button x:Name="PatientIconButton" Style="{StaticResource Subtle}"
Width="34" Height="28" Padding="0" Click="OnPickPatientClick"/>
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="DB에 저장"
Command="{Binding SaveToDbCommand}"
Content="{Binding Source=save, Converter={StaticResource IconName}, ConverterParameter=16}"/>
@@ -314,13 +356,13 @@
<Grid.ColumnDefinitions>
<!-- [200] 기본 폭 — 좌 248(레이어 행에 타입 아이콘 + 토글 2개가 들어가 232 로는 이름이 잘린다),
우 300(인스펙터 X|Y 쌍이 한 줄에 들어가는 최소치) -->
<ColumnDefinition Width="248" MinWidth="180" MaxWidth="480"/>
<ColumnDefinition x:Name="LeftColumn" Width="248" MinWidth="180" MaxWidth="480"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
<!-- 최소 250 — 글꼴 스타일 행이 라벨 92 + 토글 4칸 128 = 220px 를 요구한다
(행 가용폭 = 폭 − 스크롤바 9 − 여백 20). 220 이면 마지막 토글이 잘린다. -->
<ColumnDefinition Width="300" MinWidth="250" MaxWidth="560"/>
<ColumnDefinition x:Name="RightColumn" Width="300" MinWidth="250" MaxWidth="560"/>
</Grid.ColumnDefinitions>
<!-- 좌: 알약 탭 — 서식 목록 / 레이어 / 도구 상자 -->
@@ -344,6 +386,7 @@
<!-- 자리표시자는 TextBox 위에 겹친 TextBlock — Text 가 비었을 때만 보인다 -->
<Grid>
<TextBox x:Name="SheetSearchBox"
PreviewKeyDown="OnSheetSearchKeyDown"
Text="{Binding SheetSearchKeyword, UpdateSourceTrigger=PropertyChanged}">
<TextBox.InputBindings>
<KeyBinding Key="Enter" Command="{Binding SearchSheetsCommand}"/>
@@ -380,7 +423,8 @@
VirtualizingPanel.VirtualizationMode="Recycling"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
MouseDoubleClick="OnSheetListDoubleClick"
ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭해 엽니다">
PreviewKeyDown="OnSheetListKeyDown"
ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭하거나 Enter 로 엽니다">
<ListBox.GroupStyle>
<GroupStyle>
<GroupStyle.ContainerStyle>
@@ -460,17 +504,28 @@
</Style>
</TextBlock.Style>
</TextBlock>
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
<!--
서식명 툴팁 — 폭이 148px 남짓(자물쇠가 붙으면 134, 패널 최소폭이면 80)이라
긴 이름은 말줄임표로 잘리는데 전문을 볼 방법이 없었다.
DB에서 열기 대화상자는 열을 300px 로 주고 드래그로 늘릴 수 있었다 —
그 창을 지우기 전에 여기로 옮긴다.
'디자인 없음'과 '서식생성기 사용 꺼짐'을 툴팁으로 갈라 준다.
전에는 둘 다 같은 회색+0.6 이라 화면만 보고는 구분할 수 없었다.
-->
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis"
ToolTip="{Binding Name}">
<TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Style.Triggers>
<DataTrigger Binding="{Binding HasDesign}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
<Setter Property="ToolTip" Value="아직 디자인이 없는 서식입니다 — 열면 빈 종이로 시작합니다"/>
</DataTrigger>
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="Opacity" Value="0.6"/>
<Setter Property="ToolTip" Value="기록지 정보에서 서식생성기 사용이 꺼져 있습니다 — 열 수 없습니다"/>
</DataTrigger>
</Style.Triggers>
</Style>
@@ -563,6 +618,16 @@
</ContentControl>
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
<!--
저장 안 됨 표시. 상태(UndoService.IsDirty)는 저장 지점 깊이까지 추적해
정교하게 있었는데 화면에 한 번도 나오지 않았다 — 서식 셋을 열어 둘을 고치면
어느 것이 안 저장됐는지 알 방법이 없어, 안전하게 전부 다시 저장하게 되고
DB 저장은 그때마다 새 버전을 만든다.
-->
<Ellipse Width="6" Height="6" Margin="6,0,0,0" VerticalAlignment="Center"
Fill="{DynamicResource B.Accent}"
ToolTip="저장하지 않은 변경이 있습니다"
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVisibility}}"/>
<Button Content="✕" FontSize="11" Margin="7,0,0,0"
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
Command="{Binding DataContext.CloseDocumentCommand,
@@ -626,8 +691,11 @@
<Border.Effect>
<DynamicResource ResourceKey="B.FloatShadow"/>
</Border.Effect>
<!-- 힌트는 ScrollViewer <b>안쪽</b>이어야 한다 — ScrollViewer 도 중간 서피스를 열어
바깥에 걸면 무효다(진단 cleartype 의 I/J 표본이 그 차이다). -->
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10"
Background="{DynamicResource B.Panel}" RenderOptions.ClearTypeHint="Enabled">
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
@@ -672,7 +740,8 @@
<Border.Effect>
<DynamicResource ResourceKey="B.FloatShadow"/>
</Border.Effect>
<StackPanel Margin="5">
<StackPanel Margin="5" Background="{DynamicResource B.Panel}"
RenderOptions.ClearTypeHint="Enabled">
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
<DockPanel LastChildFill="True">
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
@@ -718,12 +787,10 @@
HorizontalAlignment="Center" Margin="0,16,0,6"/>
<TextBlock Foreground="{DynamicResource B.Muted}" FontSize="12.5" TextAlignment="Center"
TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0,0,0,20"
Text="새 서식을 만들거나, 왼쪽 서식 목록에서 기존 서식을 두 번 눌러 엽니다."/>
Text="왼쪽 서식 목록에서 서식을 두 번 눌러 엽니다. (F4 또는 Ctrl+O)"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="새 서식" Command="{Binding NewFileCommand}" Padding="16,7" Margin="0,0,8,0"/>
<Button Content="DB에서 열기" Command="{Binding OpenFromDbCommand}" Padding="16,7"
<Button Content="서식 목록에서 열기" Click="OnOpenFromListClick" Padding="16,7"
IsEnabled="{Binding CanUseDb}"/>
<Button Content="파일 열기" Command="{Binding OpenFileCommand}" Padding="16,7" Margin="8,0,0,0"/>
</StackPanel>
</StackPanel>
</Border>
@@ -738,7 +805,17 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
</Border>
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
<!--
열린 서식이 없으면 인스펙터를 <b>접는다.</b>
DataContext 가 null 이면 안쪽 Visibility 바인딩이 전부 실패하고,
WPF 는 실패한 바인딩을 기본값(Visible)으로 떨어뜨린다 —
그래서 선택이 없는데도 정렬 바와 간격값이 남아 있었다.
'새 서식'을 없애면서 시작 화면에 문서가 아예 없어졌고, 그 자리가 드러났다.
-->
<v:InspectorView x:Name="Inspector" DataContext="{Binding CurrentDesigner.Inspector}"
Visibility="{Binding DataContext.HasOpenDocuments,
RelativeSource={RelativeSource AncestorType=Window},
Converter={StaticResource BoolToVisibility}}"/>
</DockPanel>
</Border>
</Grid>
+228
View File
@@ -14,9 +14,25 @@ public partial class MainView : Window
public MainView()
{
InitializeComponent();
// 환자는 앱 전체가 하나를 공유한다 — 어느 화면에서 바뀌어도 이 아이콘이 따라와야 한다.
// 창이 살아 있는 동안만 듣는다(정적 이벤트라 떼지 않으면 창이 회수되지 않는다).
RefreshPatientIcon();
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => PatientSession.Changed -= OnPatientSessionChanged;
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
// 진단(게이트)은 창 크기를 스스로 정한다 — 저장된 취향이 끼어들면 샷 크기가 PC 마다 달라진다
if (StartupArguments.DiagnosticMode)
{
return;
}
// Show 전에 복원해야 한 프레임 번쩍이지 않는다
WindowPlacement.Restore(this);
LeftColumn.Width = new GridLength(WindowPlacement.LeftPanelWidth(LeftColumn.Width.Value));
RightColumn.Width = new GridLength(WindowPlacement.RightPanelWidth(RightColumn.Width.Value));
}
/// <summary>
@@ -36,6 +52,12 @@ public partial class MainView : Window
private void OnWindowLoaded(object sender, RoutedEventArgs e)
{
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
// 진단(게이트) 실행에서는 Loaded 커맨드를 돌리지 않는다 — 서식 목록 DB 조회가 붙어 있어서
// 검사 결과가 접속 상태와 조회 결과에 따라 달라진다. 게이트는 DB 없이 같은 답을 내야 한다.
if (StartupArguments.DiagnosticMode)
{
return;
}
if (DataContext is MainViewModel viewModel && viewModel.LoadedCommand?.CanExecute(null) == true)
{
viewModel.LoadedCommand.Execute(null);
@@ -57,6 +79,73 @@ public partial class MainView : Window
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
}
/// <summary>
/// 환자 선택 — 여기서 고르면 <b>앱 전체</b>가 그 환자를 본다.
///
/// 안 골랐으면 바로 선택 창을 연다. 이미 골랐으면 <b>메뉴</b>를 띄운다 —
/// 늘 보이는 아이콘이라 "누르면 해제"로 두면 실수로 지운다.
/// 바꾸기·해제를 글자로 보여 주고 고르게 하는 쪽이 예측 가능하다.
/// </summary>
private void OnPickPatientClick(object sender, RoutedEventArgs e)
{
if (!PatientSession.HasPatient)
{
Pick();
return;
}
var menu = new System.Windows.Controls.ContextMenu
{
PlacementTarget = PatientIconButton,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
};
var header = new System.Windows.Controls.MenuItem
{
Header = PatientSession.Label,
IsEnabled = false,
};
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
change.Click += (_, _) => Pick();
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
menu.Items.Add(header);
menu.Items.Add(new System.Windows.Controls.Separator());
menu.Items.Add(change);
menu.Items.Add(clear);
menu.IsOpen = true;
void Pick()
{
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
{
PatientSession.Set(picked, label);
}
}
}
/// <summary>
/// 아이콘이 지금 상태를 말한다 — 늘 보이는 자리라 글자를 둘 폭이 없어 <b>색</b>으로 알린다.
/// 고른 환자 이름은 툴팁에 둔다(제목줄에 환자 이름을 상시 노출하지 않는다).
/// </summary>
private void OnPatientSessionChanged(object? sender, EventArgs e) => RefreshPatientIcon();
private void RefreshPatientIcon()
{
if (PatientIconButton is null)
{
return;
}
var on = PatientSession.HasPatient;
// 안 고른 상태의 색은 <b>B.Muted</b> 다 — 옆 아이콘들(복사·삭제·테마·저장)이 XAML 컨버터의
// 기본값으로 쓰는 바로 그 키다. 전에는 null 을 넘겼고, 그러면 아이콘이 테마 토큰이 아니라
// 하드코딩 검정으로 칠해져 어두운 제목줄에서 <b>혼자만 까맣게</b> 묻혔다.
// (컴파일러가 CS8604 로 계속 경고하고 있었는데 그 경고가 가리키던 것이 이것이다.)
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : "B.Muted");
PatientIconButton.ToolTip = on
? $"환자: {PatientSession.Label}\n누르면 바꾸기·해제"
: "환자 선택 — 태그와 MDataTable 에 실제 값이 들어갑니다(앱 전체 적용)";
}
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
{
@@ -127,7 +216,146 @@ public partial class MainView : Window
if (DataContext is MainViewModel viewModel && !viewModel.IsShuttingDown && !viewModel.ConfirmCloseAll())
{
e.Cancel = true;
return;
}
// 취소되지 않은 뒤에만 저장한다 — 닫기를 물렀는데 크기가 기억되면 조작하지 않은 값이 남는다
if (!StartupArguments.DiagnosticMode)
{
WindowPlacement.Save(this, LeftColumn.ActualWidth, RightColumn.ActualWidth);
}
}
/// <summary>
/// F4 — 인스펙터 첫 편집 칸으로 들어간다.
///
/// 전에는 인스펙터로 포커스를 보내는 키가 저장소에 <b>하나도 없었다</b>.
/// 캔버스에서 Tab 을 누르면 좌측 패널·문서 탭·플로팅 바를 다 지나야 인스펙터에 닿았다.
/// 커맨드로 만들지 않는다 — 그러면 뷰모델이 뷰의 포커스를 알아야 한다.
/// </summary>
private void OnWindowPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key == System.Windows.Input.Key.F4)
{
// 인스펙터가 접혀 있으면 먼저 편다 — 안 그러면 보이지도 않는 칸으로 포커스가 들어간다
if (panelsCollapsed)
{
TogglePanels();
}
Inspector.FocusFirstRow();
e.Handled = true;
return;
}
// Ctrl+\ — 좌우 패널을 함께 접었다 편다('종이만 보기').
// 개별 토글 두 개를 두지 않는다: 목적이 하나라 키도 하나여야 손이 기억한다.
if (e.Key is System.Windows.Input.Key.OemBackslash or System.Windows.Input.Key.Oem5
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
{
TogglePanels();
e.Handled = true;
return;
}
// Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다.
// 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다.
if (e.Key == System.Windows.Input.Key.O
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
{
FocusSheetList();
e.Handled = true;
}
}
/// <summary>지금 좌우 패널이 접혀 있는가 — 접힌 폭을 '사용자가 정한 폭'으로 오해하지 않기 위한 상태</summary>
private bool panelsCollapsed;
/// <summary>펼쳤을 때의 폭 — 접는 순간 기억해 두었다가 그대로 되돌린다</summary>
private GridLength expandedLeft;
private GridLength expandedRight;
private double expandedLeftMin;
private double expandedRightMin;
/// <summary>
/// 좌우 패널을 함께 접었다 편다 — <b>종이만 보기</b>.
///
/// 좌 248 + 스플리터 5 + 스플리터 5 + 우 300 = 558px 가 <b>항상</b> 점유된다.
/// MinWidth 180/250 때문에 스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
/// 가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없었다.
///
/// <b>MinWidth 도 함께 0 으로 내려야</b> 실제로 사라진다 — Width=0 만으로는 최소폭이 이긴다.
/// 되돌릴 때 쓰려고 펼친 폭을 따로 기억한다(접힌 0 을 사용자가 정한 폭으로 착각하면 안 된다).
/// </summary>
private void TogglePanels()
{
if (panelsCollapsed)
{
LeftColumn.MinWidth = expandedLeftMin;
RightColumn.MinWidth = expandedRightMin;
LeftColumn.Width = expandedLeft;
RightColumn.Width = expandedRight;
panelsCollapsed = false;
return;
}
expandedLeft = LeftColumn.Width;
expandedRight = RightColumn.Width;
expandedLeftMin = LeftColumn.MinWidth;
expandedRightMin = RightColumn.MinWidth;
LeftColumn.MinWidth = 0;
RightColumn.MinWidth = 0;
LeftColumn.Width = new GridLength(0);
RightColumn.Width = new GridLength(0);
panelsCollapsed = true;
}
/// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary>
private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList();
/// <summary>서식 목록 탭으로 옮기고 검색칸에 커서를 둔다</summary>
private void FocusSheetList()
{
if (DataContext is MainViewModel viewModel)
{
viewModel.SelectedLeftTabIndex = 0;
}
// 탭이 방금 바뀌어 아직 시각 트리가 없다 — 레이아웃이 한 번 돈 뒤에 잡는다
// (LayerPanelView 의 검색칸 포커스와 같은 규범).
Dispatcher.BeginInvoke(new Action(() =>
{
SheetSearchBox.Focus();
System.Windows.Input.Keyboard.Focus(SheetSearchBox);
SheetSearchBox.SelectAll();
}), System.Windows.Threading.DispatcherPriority.Input);
}
/// <summary>
/// 서식 목록 키 처리 — Enter 로 연다.
///
/// 전에는 <b>더블클릭 전용</b>이라 키보드로는 서식을 열 수 없었다.
/// 'DB에서 열기' 대화상자에는 [열기] 버튼이 있어서 그 구멍이 가려져 있었다 — 그 창을 지우기 전에 메운다.
/// </summary>
private void OnSheetListKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Enter || DataContext is not MainViewModel viewModel)
{
return;
}
viewModel.OpenSheetFromList(SheetListBox.SelectedItem as SheetSummary);
e.Handled = true;
}
/// <summary>검색칸에서 ↓ 를 누르면 목록으로 내려간다 — 검색과 선택 사이에 마우스를 두지 않는다</summary>
private void OnSheetSearchKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
{
if (e.Key != System.Windows.Input.Key.Down || SheetListBox.Items.Count == 0)
{
return;
}
SheetListBox.SelectedIndex = Math.Max(0, SheetListBox.SelectedIndex);
SheetListBox.Focus();
if (SheetListBox.ItemContainerGenerator.ContainerFromIndex(SheetListBox.SelectedIndex)
is System.Windows.Controls.ListBoxItem item)
{
item.Focus();
}
e.Handled = true;
}
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="마스크 편집" Width="620" Height="620"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="12">
Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="16">
<!--
백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다.
아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다.
@@ -14,7 +14,7 @@
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="마스크 없음" Padding="12,4" Click="OnClear"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
@@ -47,6 +47,11 @@ public partial class MessageDialogView : Window
// 노트북 해상도에서 창이 화면을 덮지 않도록 한 번 더 조인다
MaxHeight = Math.Min(620, SystemParameters.WorkArea.Height * 0.85);
// 뒤를 어둡게 할 자격 — 지금 멈추고 결정해야 하는 것만.
// 물음(되돌릴 수 없는 쓰기의 확인)과 오류(진행이 정의되지 않는 차단)가 그것이다.
// 알림·경고는 읽고 지나가는 것이라 뒤를 가리지 않는다.
Services.ModalScrim.SetDimBehind(this, kind is DialogKind.Question or DialogKind.Error);
CaptionText.Text = caption;
HeadText.Text = head;
DetailText.Text = detail ?? string.Empty;
@@ -0,0 +1,151 @@
<Window x:Class="SheetMe.Designer.Views.PatientPickerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="환자 선택 — 미리보기" Width="1040" Height="620"
MinWidth="880" MinHeight="480"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="16">
<!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치,
성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock Grid.Column="0" Text="검색" VerticalAlignment="Center" Margin="0,0,8,0"
Foreground="{DynamicResource B.Muted}"/>
<ComboBox x:Name="KindCombo" Grid.Column="1" Width="110" SelectedIndex="0"
AutomationProperties.Name="검색 유형">
<ComboBoxItem Content="차트번호"/>
<ComboBoxItem Content="성명"/>
<ComboBoxItem Content="주민번호"/>
<ComboBoxItem Content="휴대전화"/>
</ComboBox>
<TextBox x:Name="TermBox" Grid.Column="2" Margin="8,0" VerticalContentAlignment="Center"
KeyDown="OnTermKeyDown" AutomationProperties.Name="검색어"/>
<Button x:Name="SearchButton" Grid.Column="3" Content="찾기(_F)" Padding="18,4" Click="OnSearch"/>
</Grid>
<!-- 감사 고지. 이 창은 실제 환자 정보를 꺼낸다 — 기록된다는 사실을 화면에서 말한다. -->
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
Foreground="{DynamicResource B.Muted}"
Text="실제 환자 정보를 불러옵니다. 조회한 사용자와 내원번호가 감사 로그에 남습니다(환자 이름은 남기지 않습니다)."/>
<!-- 아래 줄. 고른 것을 글로 확인시키고 기준 시점을 함께 둔다.
기준 시점은 입원 내원의 진료과·병실·자격 값을 실제로 바꾼다 — 숨기면 값이 왜 다른지 알 수 없다. -->
<Grid DockPanel.Dock="Bottom" Margin="0,10,0,0">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="Auto"/>
</Grid.ColumnDefinitions>
<TextBlock x:Name="ChosenText" Grid.Column="0" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource B.Muted}"
Text="환자를 찾아 내원을 고르세요."/>
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="기준 시점" VerticalAlignment="Center" Margin="0,0,6,0"
Foreground="{DynamicResource B.Muted}"
ToolTip="입원 내원은 구간이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 합니다. 외래는 접수일시로 같습니다."/>
<ComboBox x:Name="EdgeCombo" Width="150" SelectedIndex="0"
AutomationProperties.Name="기준 시점">
<ComboBoxItem Content="마지막(퇴원 시점)"/>
<ComboBoxItem Content="처음(입원 시점)"/>
</ComboBox>
<Button x:Name="SelectButton" Content="선택" Padding="20,4" Margin="10,0,0,0"
Click="OnSelect" IsDefault="True" IsEnabled="False"
Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
</Grid>
<!-- 본문. 환자 목록과 그 환자의 내원 목록을 동시에 둔다 —
고르는 대상은 내원이므로 환자를 고른 뒤 화면이 바뀌면 왕복이 생긴다. -->
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="47*"/>
<ColumnDefinition Width="6"/>
<ColumnDefinition Width="53*"/>
</Grid.ColumnDefinitions>
<DockPanel Grid.Column="0">
<TextBlock x:Name="PatientCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"
Text="환자 — 검색어를 넣고 찾기를 누르세요"/>
<ListView x:Name="PatientList" SelectionChanged="OnPatientChanged"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
VirtualizingPanel.IsVirtualizing="True"
AutomationProperties.Name="환자 목록">
<ListView.View>
<GridView>
<GridViewColumn Header="차트번호" Width="90" DisplayMemberBinding="{Binding ChtNum}"/>
<GridViewColumn Header="성명" Width="80" DisplayMemberBinding="{Binding Name}"/>
<GridViewColumn Header="주민번호" Width="105" DisplayMemberBinding="{Binding ResNumMasked}"/>
<GridViewColumn Header="휴대전화" Width="100" DisplayMemberBinding="{Binding MobilePhone}"/>
<GridViewColumn Header="비고" Width="70">
<GridViewColumn.CellTemplate>
<DataTemplate>
<TextBlock>
<TextBlock.Style>
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Style.Triggers>
<DataTrigger Binding="{Binding Inpatient}" Value="True">
<Setter Property="Text" Value="재원"/>
<Setter Property="Foreground" Value="{DynamicResource B.Accent}"/>
</DataTrigger>
<DataTrigger Binding="{Binding Active}" Value="False">
<Setter Property="Text" Value="사용중지"/>
</DataTrigger>
<DataTrigger Binding="{Binding Shielded}" Value="True">
<Setter Property="Text" Value="정보보호"/>
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
</DataTrigger>
</Style.Triggers>
</Style>
</TextBlock.Style>
</TextBlock>
</DataTemplate>
</GridViewColumn.CellTemplate>
</GridViewColumn>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch"
Background="Transparent"/>
<DockPanel Grid.Column="2">
<TextBlock x:Name="VisitCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
Foreground="{DynamicResource B.Muted}"
Text="내원 — 환자를 고르면 나옵니다"/>
<ListView x:Name="VisitList" MouseDoubleClick="OnSelect"
SelectionChanged="OnVisitChanged"
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
VirtualizingPanel.IsVirtualizing="True"
AutomationProperties.Name="내원 목록">
<ListView.View>
<GridView>
<GridViewColumn Header="구분" Width="46" DisplayMemberBinding="{Binding Kind}"/>
<GridViewColumn Header="접수일시" Width="106" DisplayMemberBinding="{Binding AcceptedAtText}"/>
<!-- 퇴원일시 — 이미 조회돼 있는데 화면에 없었다.
입원 내원이 '구간'이라는 사실이 이 열 하나로 눈에 보인다.
그래야 아래 기준 시점 선택이 무슨 뜻인지 알 수 있다. -->
<GridViewColumn Header="퇴원일시" Width="106" DisplayMemberBinding="{Binding LeftAtText}"/>
<GridViewColumn Header="진료과" Width="86" DisplayMemberBinding="{Binding Department}"/>
<GridViewColumn Header="담당의" Width="68" DisplayMemberBinding="{Binding Doctor}"/>
<GridViewColumn Header="보험" Width="74" DisplayMemberBinding="{Binding Insurance}"/>
<GridViewColumn Header="상태" Width="58" DisplayMemberBinding="{Binding State}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,273 @@
using System.Windows;
using System.Windows.Input;
using SheetMe.Core.Catalog;
using SheetMe.Data.Stores;
using SheetMe.Designer.DataBusiness;
using SheetMe.Designer.Services;
namespace SheetMe.Designer.Views;
/// <summary>
/// 미리보기에 쓸 <b>실제 환자·내원</b>을 고른다.
///
/// <b>왜 모달인가.</b> 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다.
/// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데,
/// 이 창은 실제 환자 정보를 꺼내는 창이라 <b>열려 있는 것 자체가 비용</b>이다.
///
/// <b>왜 두 목록을 한 화면에 두는가.</b> 고르는 대상은 환자가 아니라 <b>내원</b>이다.
/// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로(<see cref="PatientContextStore"/>)
/// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를
/// 다시 확인하러 왕복해야 한다.
///
/// <b>문맥을 여기서 읽는다.</b> 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고,
/// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다.
/// </summary>
public partial class PatientPickerDialogView : Window
{
#region Member Fields
private readonly PatientVisitStore? store;
private readonly PatientContextStore? contexts;
#endregion
#region Constructors
public PatientPickerDialogView()
{
InitializeComponent();
var connection = ConfigService.Current.ConnectionString;
if (connection.Length > 0)
{
store = new PatientVisitStore(connection);
contexts = new PatientContextStore(connection);
}
Loaded += (_, _) => TermBox.Focus();
}
#endregion
#region Methods
/// <summary>고른 내원의 환자 문맥 — 취소했으면 null</summary>
public PatientContext? Picked { get; private set; }
/// <summary>고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다</summary>
public string PickedLabel { get; private set; } = string.Empty;
/// <summary>
/// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다.
/// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다.
/// </summary>
public static PatientContext? Pick(Window? owner, out string label)
{
label = string.Empty;
if (!FormDesignDataBusiness.CanPreviewPatient())
{
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"실제 환자로 미리보기를 할 수 없습니다.",
FormDesignDataBusiness.PreviewPatientDenyReason(), owner);
return null;
}
var dialog = new PatientPickerDialogView { Owner = owner };
if (dialog.ShowDialog() != true || dialog.Picked is not { } picked)
{
return null;
}
// 감사 기록은 <b>고른 직후</b> 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
// 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다).
AppLog.Audit(PatientPreviewPolicy.AuditLine(
UserSession.Current.UidCod, picked.ComNum.ToString("F0")));
label = dialog.PickedLabel;
return picked;
}
private void OnTermKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
e.Handled = true;
OnSearch(sender, e);
}
}
/// <summary>검색 유형의 사람이 읽는 이름 — 0건 안내가 "무엇으로 찾았는지" 말할 때 쓴다</summary>
private static string KindLabel(PatientSearchKind kind) => kind switch
{
PatientSearchKind.Name => "성명 접두일치",
PatientSearchKind.ResidentNumber => "주민번호 접두일치",
PatientSearchKind.MobilePhone => "휴대전화 부분일치",
_ => "차트번호 완전일치",
};
private void OnSearch(object sender, RoutedEventArgs e)
{
if (store is null)
{
DialogService.Notify(DialogKind.Warning, "환자 검색",
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
return;
}
var kind = KindCombo.SelectedIndex switch
{
1 => PatientSearchKind.Name,
2 => PatientSearchKind.ResidentNumber,
3 => PatientSearchKind.MobilePhone,
_ => PatientSearchKind.ChartNumber,
};
VisitList.ItemsSource = null;
VisitCountText.Text = "내원";
try
{
// 동기로 둔다. 행 상한이 200이라 오래 걸리지 않고, 비동기로 만들면
// "검색 중에 또 검색"을 다뤄야 해서 이 창 크기에 맞지 않는 복잡도가 생긴다.
Mouse.OverrideCursor = Cursors.Wait;
SearchButton.IsEnabled = false;
var found = store.Search(kind, TermBox.Text);
var padded = string.Empty;
// 차트번호는 <b>완전일치</b>다. 병원마다 자릿수가 정해져 있어 실제 값은 0으로 채워져 있는데
// (P_PatInf 가 고정 폭이다) 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
// 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데 이 호출부가 그걸 안 지켰다.
//
// <b>자릿수를 설정으로 정하지 않는다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
// 대신 0건일 때만 한 번 더 찾는다 — 8·10자리는 레거시가 쓰던 두 폭이다.
// 찾았으면 무엇으로 찾았는지 칸에 되써서 사용자가 그 번호를 기억하게 한다.
if (found.Count == 0 && kind == PatientSearchKind.ChartNumber
&& TermBox.Text.Trim() is { Length: > 0 } typed && typed.All(char.IsDigit))
{
foreach (var width in new[] { 8, 10 })
{
if (typed.Length >= width)
{
continue;
}
var retry = store.Search(kind, typed.PadLeft(width, '0'));
if (retry.Count > 0)
{
found = retry;
padded = typed.PadLeft(width, '0');
TermBox.Text = padded;
break;
}
}
}
PatientList.ItemsSource = found;
PatientCountText.Text = found.Count switch
{
// 0건일 때 <b>무엇으로 찾았는지</b> 말한다 — 유형이 틀려서 0건인 경우가 가장 흔한데
// 전에는 그 힌트가 화면에 하나도 없어 접속이나 권한을 의심하게 됐다
0 => $"환자 — 없습니다({KindLabel(kind)}로 찾았습니다)",
_ when padded.Length > 0 => $"환자 {found.Count}명 — 자릿수를 채워 '{padded}' 로 찾았습니다",
PatientVisitStore.DefaultLimit =>
$"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요",
_ => $"환자 {found.Count}명",
};
if (found.Count > 0)
{
PatientList.SelectedIndex = 0;
PatientList.Focus();
}
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "환자 검색", "검색 중 오류가 발생했습니다.", ex.Message, this);
}
finally
{
SearchButton.IsEnabled = true;
Mouse.OverrideCursor = null;
}
}
private void OnPatientChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
{
UpdateChosen();
if (store is null || PatientList.SelectedItem is not PatientSummary patient)
{
return;
}
try
{
Mouse.OverrideCursor = Cursors.Wait;
var visits = store.ListVisits(patient.ChtNum);
VisitList.ItemsSource = visits;
VisitCountText.Text = visits.Count == 0 ? "내원 — 없습니다" : $"내원 {visits.Count}건";
if (visits.Count > 0)
{
VisitList.SelectedIndex = 0;
}
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "내원 조회", "내원을 읽지 못했습니다.", ex.Message, this);
}
finally
{
Mouse.OverrideCursor = null;
}
}
private void OnVisitChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
=> UpdateChosen();
/// <summary>고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다</summary>
private void UpdateChosen()
{
var patient = PatientList.SelectedItem as PatientSummary;
var visit = VisitList.SelectedItem as VisitSummary;
SelectButton.IsEnabled = patient is not null && visit is not null;
ChosenText.Text = (patient, visit) switch
{
(null, _) => "환자를 찾아 내원을 고르세요.",
({ } p, null) => $"{p.Name}({p.ChtNum}) — 내원을 고르세요.",
({ } p, { } v) => $"{p.Name}({p.ChtNum}) · {v.Kind} {v.AcceptedAtText} · 내원 {v.ComNum:F0}",
};
}
private void OnSelect(object sender, RoutedEventArgs e)
{
if (PatientList.SelectedItem is not PatientSummary patient
|| VisitList.SelectedItem is not VisitSummary visit)
{
return;
}
if (contexts is null)
{
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
return;
}
var edge = EdgeCombo.SelectedIndex == 1 ? VisitEdge.First : VisitEdge.Last;
try
{
Mouse.OverrideCursor = Cursors.Wait;
var context = contexts.Load(visit.ComNum, edge);
if (context is null)
{
// 목록에는 있는데 다시 읽을 때 없다 — 그 사이에 지워졌거나 권한이 다르다.
// 창을 닫지 않는다. 닫으면 미리보기가 조용히 빈 종이를 낸다.
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
"이 내원의 정보를 읽지 못했습니다.",
$"내원 {visit.ComNum:F0} 을 다시 읽을 수 없습니다. 목록을 새로 검색해 보세요.", this);
return;
}
Picked = context;
PickedLabel = $"{patient.Name}({patient.ChtNum}) · {visit.Kind}"
+ $" {visit.AcceptedAtText} · 기준 {Readable(context.AdpDtm)}";
DialogResult = true;
}
catch (Exception ex)
{
DialogService.Notify(DialogKind.Error, "환자 미리보기",
"환자 정보를 읽는 중 오류가 발생했습니다.", ex.Message, this);
}
finally
{
Mouse.OverrideCursor = null;
}
}
private static string Readable(string stamp)
=> stamp.Length == 12
? $"{stamp[..4]}-{stamp[4..6]}-{stamp[6..8]} {stamp[8..10]}:{stamp[10..12]}"
: stamp;
#endregion
}
+76 -5
View File
@@ -1,22 +1,93 @@
<Window x:Class="SheetMe.Designer.Views.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="미리보기" Width="900" Height="1000"
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}"
Style="{StaticResource ThemedWindow}">
Style="{StaticResource ThemedDialogWindow}">
<Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
</Window.Resources>
<DockPanel>
<ToolBarTray DockPanel.Dock="Top">
<ToolBar>
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/>
<!--
아이콘 버튼으로 둔다. 전에는 여덟 개가 전부 글자라 툴바가 글자 벽이었고,
무엇이 명령이고 무엇이 상태인지 구분되지 않았다.
글자로 남기는 것은 <b>값과 상태</b>뿐이다 — 고른 환자 이름과 배율.
이름은 툴팁이 받는다(셸 제목줄의 아이콘 줄과 같은 규범).
-->
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnPrint" ToolTip="인쇄..."
Content="{Binding Source=printer, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnRefresh"
ToolTip="다시 그리기 — 구조 변경은 자동으로 따라오고, 창을 누르면 다시 그립니다"
Content="{Binding Source=rotate-cw, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/>
<!--
레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를
그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다.
기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다.
토글은 눌린 상태가 곧 '인쇄 기준'이라는 뜻이다(툴팁이 양쪽을 설명한다).
-->
<ToggleButton x:Name="PrintViewToggle" IsChecked="True"
Style="{StaticResource MiniToggle}" Width="34" Height="28" Padding="0"
Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="인쇄될 것만 보기 — 끄면 화면에 보이는 대로(인쇄 출력을 끈 컨트롤도 보입니다)"
Content="{Binding Source=eye, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Separator/>
<!--
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
선택·해제를 <b>한 버튼</b>으로 합쳤다 — 셸 제목줄의 환자 아이콘과 같은 방식이다.
안 골랐으면 바로 선택 창, 이미 골랐으면 바꾸기·해제 메뉴를 띄운다.
늘 보이는 자리에서 '누르면 해제'로 두면 실수로 지운다.
-->
<Button x:Name="PatientButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnPickPatient"
ToolTip="환자 선택 — 실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
<!--
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
-->
<Button x:Name="DataSourceButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnShowDataSources" IsEnabled="False"
ToolTip="데이터소스 결과 — MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"
Content="{Binding Source=table, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!--
미리보기에서 눌러 본 체크·라디오를 원래대로. 문서는 처음부터 안 건드리므로
되돌릴 것이 없고, 여기서 지우는 것은 "눌러 본 흔적"뿐이다.
-->
<Button x:Name="ResetChecksButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
Click="OnResetChecks" IsEnabled="False"
ToolTip="누른 것 초기화 — 미리보기에서 눌러 본 체크·라디오를 서식의 기본 상태로 돌립니다"
Content="{Binding Source=undo-2, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!-- 고른 환자는 <b>글자로</b> 남긴다 — 이름은 아이콘으로 대신할 수 없는 값이다 -->
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<Separator/>
<Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
Click="OnZoomOut" ToolTip="축소"
Content="{Binding Source=minus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
<Button Content="" Padding="8,3" Click="OnZoomIn"/>
<Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
Click="OnZoomIn" ToolTip="확대"
Content="{Binding Source=plus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
</ToolBar>
</ToolBarTray>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center">
<!--
종이를 눌러 본다 — 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
체크·라디오를 실제로 눌러 확인한다. 클릭은 미리보기 안에서만 살고 문서를 바꾸지 않는다.
-->
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"
MouseLeftButtonUp="OnPaperClick" MouseMove="OnPaperMouseMove">
<StackPanel.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform>
@@ -1,31 +1,171 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Documents;
using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views;
/// <summary>미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.</summary>
/// <summary>
/// 미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.
///
/// <b>창은 하나만 뜬다.</b> 전에는 누를 때마다 새 창이 생겼다. 모덜리스라 여러 장이 겹쳐 쌓이고,
/// 각각이 <b>서로 다른 시점의 서식</b>을 들고 있었다. 어느 것이 지금 것인지 화면으로 구분할 수 없다.
/// 환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 바꿔도 옛 창이 옛 환자를 계속 띄운다.
///
/// <b>내용은 따라간다.</b> 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제하거나
/// 숨김·인쇄출력을 토글해도 창은 옛 그림을 계속 보여 줬다 —
/// 저장 전에 결과를 확인하려고 여는 창이 <b>구조적으로 거짓말</b>을 하고 있었다.
/// 페이지·컨트롤 추가/삭제는 즉시, 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다.
/// 즉시 갱신이 안 되는 변경이 남아 있으므로 <b>새로 고침 버튼</b>도 함께 둔다.
/// </summary>
public partial class PreviewWindow : Window
{
#region Member Fields
private readonly DesignerViewModel designer;
/// <summary>지금 떠 있는 창 — 두 번째를 만들지 않는다</summary>
private static PreviewWindow? open;
private DesignerViewModel designer;
private double zoom = 1.0;
/// <summary>
/// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
/// </summary>
private readonly SessionTagResolver session = new();
/// <summary>
/// 지금 쓰는 해석기. 환자를 고르면 <see cref="PatientTagResolver"/> 가 앞에 붙고,
/// 해제하면 다시 <see cref="session"/> 뿐이다 — 환자를 해제했는데 옛 값이 남으면
/// 화면이 실제와 다른 것을 보여 준다.
/// </summary>
private SheetMe.Core.Catalog.ITagValueResolver tags;
/// <summary>
/// MDataTable 실행기 — <c>DataTableField</c> 배선의 값을 낸다.
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
/// </summary>
private MDataTableRunner? fields;
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
private SheetMe.Data.Stores.PatientContext? patient;
/// <summary>
/// 이 창에서 눌러 본 체크·라디오 — <b>문서에는 쓰지 않는다</b>.
/// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
/// </summary>
private readonly SheetMe.Core.Catalog.PreviewCheckState checks = new();
#endregion
#region Constructors
public PreviewWindow(DesignerViewModel designer)
/// <summary>
/// 진단(--dialog-shots)만 직접 부른다 — 앱 코드는 <see cref="ShowFor"/> 를 쓸 것.
/// 여기로 만들면 단일 인스턴스 등록을 건너뛰므로 두 장이 생긴다.
/// </summary>
internal PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
tags = session;
InitializeComponent();
BuildPages();
Activated += (_, _) => BuildPages();
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) =>
{
Unsubscribe();
PatientSession.Changed -= OnPatientSessionChanged;
if (ReferenceEquals(open, this))
{
open = null;
}
};
Subscribe();
// 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
UsePatient(PatientSession.Current, PatientSession.Label);
}
#endregion
#region Methods
/// <summary>
/// 미리보기를 보여 준다 — 이미 떠 있으면 그 창을 <b>지금 문서로 바꿔</b> 앞으로 가져온다.
/// 새 창을 하나 더 만들면 어느 것이 지금 것인지 알 수 없다.
/// </summary>
public static void ShowFor(DesignerViewModel designer, Window? owner)
{
if (open is null)
{
open = new PreviewWindow(designer) { Owner = owner };
open.Show();
return;
}
open.Retarget(designer);
if (open.WindowState == WindowState.Minimized)
{
open.WindowState = WindowState.Normal;
}
open.Activate();
}
/// <summary>다른 문서로 갈아탄다 — 탭을 바꾸고 미리보기를 다시 누른 경우</summary>
private void Retarget(DesignerViewModel next)
{
if (ReferenceEquals(designer, next))
{
BuildPages();
return;
}
Unsubscribe();
designer = next;
Subscribe();
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
UsePatient(patient, PatientText.Text);
}
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
private void Subscribe()
{
designer.Pages.CollectionChanged += OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged += OnStructureChanged;
}
}
private void Unsubscribe()
{
designer.Pages.CollectionChanged -= OnStructureChanged;
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
}
}
private void OnStructureChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
{
// 페이지가 늘거나 줄면 그 페이지의 컨트롤 구독도 다시 걸어야 한다
if (ReferenceEquals(sender, designer.Pages))
{
foreach (var page in designer.Pages)
{
page.Controls.CollectionChanged -= OnStructureChanged;
page.Controls.CollectionChanged += OnStructureChanged;
}
}
BuildPages();
}
private void BuildPages()
{
// XAML 파싱 중에도 불린다 — 토글의 IsChecked="True" 가 파스 시점에 Checked 를 발생시키는데
// 그때 PagesHost 는 아직 만들어지지 않았다(토글이 트리에서 앞에 있다).
// 이 가드가 없으면 InitializeComponent 안에서 NullReferenceException 이 난다.
if (PagesHost is null)
{
return;
}
PagesHost.Children.Clear();
foreach (var page in designer.Pages)
{
@@ -35,21 +175,249 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields, checks),
Width = page.WidthDip,
Height = page.HeightDip,
// 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
Tag = page,
};
PagesHost.Children.Add(frame);
}
ResetChecksButton.IsEnabled = checks.HasAny;
Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}"
: "미리보기";
}
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 종이 위 체크·라디오를 눌렀다 — <b>미리보기 안에서만</b> 상태가 바뀐다.
///
/// <b>왜 이 창에서 클릭이 되어야 하나.</b> 레거시 미리보기는 정적 그림이 아니라 런타임 모드
/// (<c>TK_PREVIEW</c>)라서 실제로 눌러 보며 확인한다. 라디오가 배타로 묶였는지는
/// <b>눌러 보는 것 말고 확인할 방법이 없다</b> — 패널로 묶었다고 믿고 저장했다가
/// 임상 화면에서 두 개가 동시에 켜지는 것이 이 서식에서 가장 흔한 사고다.
///
/// <b>왜 문서를 안 건드리나.</b> 확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은
/// 것만으로 저장할 내용이 생긴다. 그래서 상태는 <see cref="checks"/> 에만 쌓고 렌더에서 덮는다.
///
/// <b>왜 WPF 히트테스트가 아닌가.</b> 종이 비주얼은 템플릿으로 만든 그림이라
/// 컨트롤 하나가 여러 요소(테두리·글자·표시)로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
/// 모델 트리를 좌표로 훑으면 캔버스의 <c>HitTestControl</c> 과 같은 규칙을 그대로 쓸 수 있다.
/// </summary>
private void OnPaperClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (Locate(e) is not { } target)
{
return;
}
checks.Click(target.Hit.Model, target.Siblings.Select(vm => vm.Model).ToList());
BuildPages();
}
/// <summary>누를 수 있는 것 위에서는 손가락 커서 — 안 그러면 클릭이 되는지 알 수 없다</summary>
private void OnPaperMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
=> Cursor = Locate(e) is null
? System.Windows.Input.Cursors.Arrow
: System.Windows.Input.Cursors.Hand;
/// <summary>
/// 마우스 위치의 누를 수 있는 컨트롤과 <b>그 직계 형제들</b> — 없으면 null.
///
/// 형제 목록까지 함께 내는 이유: 라디오 배타는 직계 부모가 가른다
/// (레거시 RadioButton 은 <c>GroupName</c> 이 없다) — 누른 것만 알면 무엇을 꺼야 할지 모른다.
/// </summary>
private (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? Locate(
System.Windows.Input.MouseEventArgs e)
{
var printFilter = PrintViewToggle.IsChecked == true;
foreach (var child in PagesHost.Children)
{
if (child is not Border { Tag: PageViewModel page, Child: { } paper })
{
continue;
}
// 종이(Canvas) 기준 좌표로 받는다 — 테두리 1px 과 줌 배율이 여기서 함께 풀린다
var local = e.GetPosition(paper);
if (local.X < 0 || local.Y < 0 || local.X > page.WidthDip || local.Y > page.HeightDip)
{
continue;
}
return HitInPreview(page.Controls, local, printFilter);
}
return null;
}
/// <summary>
/// 그려진 것 중 좌표 위 최상위 컨트롤 — 컨테이너는 자식 우선.
///
/// 캔버스의 히트테스트와 두 군데가 다르다.
/// ① <c>Locked</c> 를 보지 않는다 — 잠금은 편집 보호일 뿐 런타임에서는 눌린다.
/// ② 그려지지 않은 것(숨김·데이터소스·인쇄 제외)은 뺀다 — 안 보이는 것을 누를 수는 없다.
///
/// 맨 위가 누를 수 없는 것(라벨 등)이면 그대로 아무 일도 없다 — 아래로 흘리지 않는다.
/// WinForms 도 맨 위 컨트롤에만 클릭을 준다.
/// </summary>
private static (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? HitInPreview(
IList<ControlViewModel> controls, Point local, bool printFilter)
{
for (var i = controls.Count - 1; i >= 0; i--)
{
var vm = controls[i];
if (vm.Model.Hidden || vm is DataTableViewModel
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(vm.Model)))
{
continue;
}
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
if (!rect.Contains(local))
{
continue;
}
if (vm is ContainerViewModel container
&& HitInPreview(container.Children, new Point(local.X - vm.X, local.Y - vm.Y), printFilter)
is { } childHit)
{
return childHit;
}
return SheetMe.Core.Catalog.PreviewCheckState.IsClickable(vm.Model)
? (vm, controls.ToList())
: null;
}
return null;
}
/// <summary>눌러 본 것을 전부 원래대로 — 문서는 애초에 안 바뀌었으니 되돌릴 것이 없다</summary>
private void OnResetChecks(object sender, RoutedEventArgs e)
{
checks.Clear();
BuildPages();
}
/// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다.
///
/// 권한 문·감사 기록·문맥 읽기는 모두 <see cref="PatientPickerDialogView.Pick"/> 안에 있다 —
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
/// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e)
{
// 안 골랐으면 바로 선택 창. 이미 골랐으면 <b>메뉴</b>다 —
// 늘 보이는 아이콘에서 '누르면 해제'로 두면 실수로 지운다.
// 셸 제목줄의 환자 아이콘과 같은 규범이다(버튼 두 개를 하나로 합쳤다).
if (!PatientSession.HasPatient)
{
Pick();
return;
}
var menu = new System.Windows.Controls.ContextMenu
{
PlacementTarget = PatientButton,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
};
var header = new System.Windows.Controls.MenuItem { Header = PatientSession.Label, IsEnabled = false };
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
change.Click += (_, _) => Pick();
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
menu.Items.Add(header);
menu.Items.Add(new System.Windows.Controls.Separator());
menu.Items.Add(change);
menu.Items.Add(clear);
menu.IsOpen = true;
void Pick()
{
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다.
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다.
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
{
PatientSession.Set(picked, label);
}
}
}
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
private void OnPatientSessionChanged(object? sender, EventArgs e)
=> UsePatient(PatientSession.Current, PatientSession.Label);
/// <summary>
/// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
///
/// <b>모달과 분리하는 이유</b>: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
/// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
/// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
/// </summary>
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
{
patient = picked;
// 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
// 손으로 누른 값이 그 위에 남아 있으면 <b>새 환자의 실제 값을 가린다</b> —
// 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
checks.Clear();
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
// 가장 민감한 값을 여러 번 꺼내게 된다.
tags = picked is null
? session
: new PatientTagResolver(picked, session,
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
PatientIdentity.DoctorOf(picked), PatientIdentity.StaffDoctorOf(picked),
PatientIdentity.DepartmentOf(picked),
PatientIdentity.HospitalRowOf(picked),
PatientIdentity.DiagnosisSourceFor(picked),
PatientIdentity.SurgerySourceFor(picked),
PatientIdentity.ReviewDiagnosisSourceFor(picked),
PatientIdentity.VitalSourceFor(picked),
PatientIdentity.ScheduledSurgerySourceFor(picked),
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
PatientIdentity.ShareMap());
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label;
// 고른 상태는 <b>아이콘 색</b>으로 알린다 — 셸 제목줄과 같다.
// 안 고른 상태의 색은 B.Muted 여야 한다(null 을 넘기면 하드코딩 검정으로 칠해져
// 어두운 툴바에서 혼자 묻힌다 — 제목줄에서 실제로 겪은 결함이다).
PatientButton.Content = Controls.LucideIcons.Icon("user", 16,
picked is null ? "B.Muted" : "B.Accent");
DataSourceButton.IsEnabled = fields.Names.Count > 0;
BuildPages();
}
/// <summary>
/// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 <b>왜</b>인지는 여기서만 알 수 있다.
///
/// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
/// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
/// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
/// </summary>
private void OnShowDataSources(object sender, RoutedEventArgs e)
{
if (fields is null)
{
return;
}
DataSourceResultDialogView.ShowFor(fields, this);
}
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
private void OnPrint(object sender, RoutedEventArgs e)
{
try
{
// 이 창이 종이에 그리는 것과 <b>같은 해석기</b>로 인쇄한다.
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
: designer.Document.FormId);
: designer.Document.FormId, tags, fields, checks);
}
catch (Exception ex)
{
@@ -4,7 +4,7 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
Style="{StaticResource ThemedDialogWindow}">
<Window.Resources>
<!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 -->
<ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/>
@@ -241,6 +241,14 @@
<TextBlock Text="검증 실행" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--
환자 선택 — 검증 실행을 <b>실제 값</b>으로 돌리려면 여기서 고를 수 있어야 한다.
전에는 미리보기 창 안에만 있어서, 쿼리를 고치는 사람은 값 확인을 할 수 없었다
(변수가 NULL 로 들어가 늘 0행이었고, 그건 "안 된다"로 읽힌다).
고른 환자는 세션 하나를 공유하므로 미리보기와 같은 환자다.
-->
<Button x:Name="PatientButton" Padding="14,8" Margin="10,0,0,0" Click="OnPickPatient"
ToolTip="환자를 고르면 치환 변수에 실제 값이 들어가 값까지 확인됩니다"/>
<Button Content="확인" Padding="30,8" Margin="10,0,0,0"
Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="26,8" Margin="10,0,0,0" IsCancel="True"/>
@@ -259,7 +267,11 @@
<!-- ══ SQL 편집기 카드 ══ -->
<Border Grid.Column="0" Style="{StaticResource Card}">
<DockPanel Margin="14,12,14,12">
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<TextBlock DockPanel.Dock="Top" Text="SQL 편집기" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/>
@@ -305,13 +317,39 @@
</DockPanel>
</Border>
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10">
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10"
Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="닫기"
Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/>
<!--
무엇을 돌렸는지 보여 준다. 0행이 나왔을 때
'치환이 NULL 이라 조건이 사라진 건지, 이 환자에게 자료가 없는 건지'를
창을 나가지 않고 가르는 유일한 수단이다 — 치환을 마친 문장이 답을 갖고 있다.
-->
<Button x:Name="ShowTrialSqlButton" DockPanel.Dock="Right"
Content="돌린 SQL 보기" Margin="0,0,10,0"
Style="{StaticResource LinkBtn}" Click="OnToggleTrialSql"/>
<TextBlock x:Name="TrialSummary" FontSize="12.5" TextWrapping="Wrap"
VerticalAlignment="Center"/>
</DockPanel>
<!-- 돌린 SQL 원문 — 읽기 전용. 08c 데이터소스 결과 창과 같은 구성이다 -->
<Border x:Name="TrialSqlPane" DockPanel.Dock="Top" Visibility="Collapsed"
Margin="0,0,0,8" BorderThickness="1"
BorderBrush="{DynamicResource B.Line2}"
Background="{DynamicResource B.Input}">
<DockPanel>
<Button DockPanel.Dock="Right" Content="SQL 복사" VerticalAlignment="Top"
Margin="6,4,6,4" Style="{StaticResource LinkBtn}"
Click="OnCopyTrialSql"/>
<TextBox x:Name="TrialSqlText" IsReadOnly="True" BorderThickness="0"
Background="Transparent" FontFamily="Consolas" FontSize="12"
TextWrapping="Wrap" MaxHeight="120" Padding="8,6"
VerticalScrollBarVisibility="Auto"
AutomationProperties.Name="돌린 SQL"/>
</DockPanel>
</Border>
<!-- 표를 감싸는 테두리 — 표 자체는 테두리를 그리지 않는다(위 스타일) -->
<Border BorderThickness="1" BorderBrush="{DynamicResource B.Line2}">
<DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200"
@@ -326,8 +364,13 @@
본문 사이 구분선까지 함께 휘어 보인다. -->
<!-- 본문 면은 B.Surface 다. B.Input 을 쓰면 다크에서 홈통(B.Chip)과 같은 #262626 이라
홈통이 통째로 사라진다(양 토큰이 같은 값이다). -->
<!-- ClearTypeHint 를 여기에 건다. 카드 DockPanel 에만 걸어 봤더니 SQL 글자는
계속 뭉개졌다 — 본문은 이 아래 SqlHighlightLayer 가 그리고, 그 사이에
또 다른 중간 서피스가 있다(진단 cleartype 표본 I·J — ScrollViewer 도 경계다).
이 Border 는 불투명 B.Surface 를 갖고 Effect 경계 안쪽이라 조건이 맞는다. -->
<Border BorderThickness="1" CornerRadius="0"
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}">
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -393,14 +436,21 @@
<!-- ══ 치환 변수 카드 ══ -->
<Border x:Name="VariablePane" Grid.Column="2" Style="{StaticResource Card}">
<DockPanel Margin="14,12,14,12">
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/>
<!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<!-- KeyDown 필수 — 확인 버튼이 IsDefault 라 이 칸의 Enter 가
'저장하고 닫기'로 새어 나간다(변수를 못 넣은 채 편집이 끝난다) -->
<TextBox x:Name="VariableSearch" Padding="28,4,8,4"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
KeyDown="OnVariableSearchKeyDown"
AutomationProperties.Name="치환 변수 검색"
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False"
@@ -439,6 +489,19 @@
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="치환 변수 목록">
<!--
ListBox 는 템플릿 안에 ScrollViewer 를 품고 있고 그것이 중간 서피스다 —
카드에 건 힌트는 <b>바깥</b>이라 목록 글자에는 듣지 않는다(표본 I).
항목 패널을 불투명하게 만들어 힌트를 ScrollViewer <b>안쪽</b>에 둔다(표본 J).
StackPanel 이 아니라 VirtualizingStackPanel 이어야 한다 —
바꾸면 변수 120개가 전부 실체화된다.
-->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,3" ToolTip="{Binding Token}">

Some files were not shown because too many files have changed in this diff Show More