Compare commits

..
83 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
98 changed files with 16615 additions and 383 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> /// </summary>
public static PropertyDef DataTableField => new() 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
}
@@ -25,3 +25,17 @@ public interface ITagValueResolver
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary> /// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
TagValue Resolve(string tag); 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_주상병명_Refer",
"OCM_주상병명_영어", "OCM_주상병명_영어",
"OCM_주상병명_영어_Refer", "OCM_주상병명_영어_Refer",
// 카탈로그 전수 추출(2026-07-16) 이후 레거시에 추가된 함수 —
// bzDataInterface.vb:10429 실존을 확인하고 등재했다(이름 게이트가 잡았다)
"OCM_주상병명_주상병코드",
"OCM_주상병일자", "OCM_주상병일자",
"OCM_주상병일자_Refer", "OCM_주상병일자_Refer",
"OCM_주상병코드", "OCM_주상병코드",
@@ -14,28 +14,36 @@ namespace SheetMe.Core.Catalog;
/// (전체 검색 + 감사 로그), 그래서 <b>남기는 쪽</b>이 안전장치의 본체다 — /// (전체 검색 + 감사 로그), 그래서 <b>남기는 쪽</b>이 안전장치의 본체다 —
/// 조회 자체를 막지 않는 대신 흔적을 남긴다. /// 조회 자체를 막지 않는 대신 흔적을 남긴다.
/// ///
/// 조건은 <see cref="ModifyPolicy"/> 와 같은 축을 쓴다. 환자 기록을 보는 일은 /// <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> /// </summary>
public static class PatientPreviewPolicy public static class PatientPreviewPolicy
{ {
#region Methods #region Methods
/// <summary> /// <summary>
/// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가. /// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가 — <b>인증만</b> 요구한다.
/// ///
/// 인증된 HIS 사용자여야 한다 — 미인증이면 감사 로그에 남길 주체가 없고, /// 미인증이면 감사 로그에 남길 주체가 없고, 주체 없는 환자 조회는 남기면 안 되는 일이다.
/// 주체 없는 환자 조회는 그 자체로 남기면 안 되는 일이다. /// 그것이 유일한 조건이다. 병원·부서 인자는 <b>일부러 받아 두고 쓰지 않는다</b> —
/// 병원별 규칙이 생기면 호출부를 고치지 않고 여기서 받을 수 있어야 하고,
/// 인자가 없으면 그때 호출부 전부를 다시 손대야 한다.
/// </summary> /// </summary>
public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod, public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod,
string hospitalStartDate) string hospitalStartDate)
=> uidCod.Length > 0 => uidCod.Length > 0;
&& ModifyPolicy.CanModify(hospitalCode, departmentCode, uidCod, hospitalStartDate);
/// <summary>왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다</summary> /// <summary>왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다</summary>
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate) public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
=> uidCod.Length == 0 => uidCod.Length == 0
? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요." ? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요."
: ModifyPolicy.DenyReason(hospitalCode, uidCod, hospitalStartDate); : string.Empty;
/// <summary> /// <summary>
/// 감사 로그 한 줄 — 누가·언제·어느 내원을 열었는가. /// 감사 로그 한 줄 — 누가·언제·어느 내원을 열었는가.
@@ -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
}
@@ -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
}
+67 -5
View File
@@ -47,7 +47,10 @@ public static class TagPreviewCatalog
["ETC_현재일자"] = ClockFormat.Date, ["ETC_현재일자"] = ClockFormat.Date,
["ETC_현재일시"] = ClockFormat.DateTime, ["ETC_현재일시"] = ClockFormat.DateTime,
["ETC_현재일시_MaskBox"] = ClockFormat.DateTimeMask, ["ETC_현재일시_MaskBox"] = ClockFormat.DateTimeMask,
["ETC_현재년도"] = ClockFormat.Year, // "년" 접미가 붙는다(bzDataInterface.vb:16923 — Data2Format(...) + "년").
// 처음에 접미 없는 Year 로 넣어 레거시와 다른 값("2026")이 나갔다 —
// 형제 태그 ETC_현재일시_1_년(:16929)은 접미가 <b>없으므로</b> 둘을 갈라야 한다.
["ETC_현재년도"] = ClockFormat.YearSuffixed,
["ETC_현재시간"] = ClockFormat.Time, ["ETC_현재시간"] = ClockFormat.Time,
["ETC_현재일자_영문"] = ClockFormat.DateEnglish, ["ETC_현재일자_영문"] = ClockFormat.DateEnglish,
["ETC_현재일시_1_년"] = ClockFormat.Year, ["ETC_현재일시_1_년"] = ClockFormat.Year,
@@ -65,6 +68,16 @@ public static class TagPreviewCatalog
["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName, ["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName,
["ETC_로그인_사용자연락처"] = UserField.MobilePhone, ["ETC_로그인_사용자연락처"] = UserField.MobilePhone,
["ETC_로그인_원내연락처"] = UserField.OfficePhone, ["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> /// <summary>
@@ -81,6 +94,11 @@ public static class TagPreviewCatalog
// 환자를 골라도 영원히 안 나온다. 사유를 정직하게 하려고 넣는다. // 환자를 골라도 영원히 안 나온다. 사유를 정직하게 하려고 넣는다.
["ETC_건강보험증번호_Refer"] = TagPreviewKind.Dead, ["ETC_건강보험증번호_Refer"] = TagPreviewKind.Dead,
// 값은 UidNam 을 담고 조건은 UidCod 로 건다(bzDataInterface.vb:17262·17272) —
// 실무상 0건이라 레거시에서 늘 빈칸이던 태그다. 고치면 없던 값이 갑자기 채워지므로
// 출시 전 합의 전까지 레거시 동작(항상 빈 값)을 유지한다.
["ETC_로그인_직급"] = TagPreviewKind.Dead,
// 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다. // 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다.
// 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다. // 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다.
["퇴원약DT"] = TagPreviewKind.NotATag, ["퇴원약DT"] = TagPreviewKind.NotATag,
@@ -88,6 +106,25 @@ public static class TagPreviewCatalog
["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag, ["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag,
["GetNurseEmrDT"] = TagPreviewKind.NotATag, ["GetNurseEmrDT"] = TagPreviewKind.NotATag,
["GetNurseShtCod"] = 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 #endregion
@@ -120,12 +157,16 @@ public static class TagPreviewCatalog
{ {
return TagPreviewKind.Table; return TagPreviewKind.Table;
} }
// 싸인·서명·직인·로고 — 값은 <b>이미지 파일 경로</b>다. 환자 해석기가 경로를 얻어
// 이 단말에서 파일을 찾고(레거시 캐시 → 경로 자체), PictureBox 가 그린다.
// 파일서버 전송(ActiveX)은 옮기지 않았으므로 캐시가 없는 단말에서는 사유가 된다.
// 분류는 PatientContext 다 — Image 로 두면 "글자만 보여 준다"는 <b>틀린 사유</b>가 나간다.
if (tag.Contains("싸인", StringComparison.Ordinal) if (tag.Contains("싸인", StringComparison.Ordinal)
|| tag.Contains("서명", StringComparison.Ordinal) || tag.Contains("서명", StringComparison.Ordinal)
|| tag.Contains("직인", StringComparison.Ordinal) || tag.Contains("직인", StringComparison.Ordinal)
|| tag.Contains("로고", StringComparison.Ordinal)) || tag.Contains("로고", StringComparison.Ordinal))
{ {
return TagPreviewKind.Image; return TagPreviewKind.PatientContext;
} }
return TagPreviewKind.PatientContext; return TagPreviewKind.PatientContext;
} }
@@ -174,6 +215,7 @@ public static class TagPreviewCatalog
// 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치 // 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치
ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}", ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}",
ClockFormat.Year => date[..4], ClockFormat.Year => date[..4],
ClockFormat.YearSuffixed => date[..4] + "년",
ClockFormat.Month => date[4..6], ClockFormat.Month => date[4..6],
ClockFormat.Day => date[6..8], ClockFormat.Day => date[6..8],
ClockFormat.Hour => minute12[8..10], ClockFormat.Hour => minute12[8..10],
@@ -185,6 +227,16 @@ public static class TagPreviewCatalog
/// <summary>로그인 사용자 정보에서 이 태그의 값을 만든다</summary> /// <summary>로그인 사용자 정보에서 이 태그의 값을 만든다</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office) 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)) if (!UserTags.TryGetValue(tag, out var field))
{ {
@@ -194,15 +246,25 @@ public static class TagPreviewCatalog
{ {
UserField.Name => name, UserField.Name => name,
UserField.Id => id, UserField.Id => id,
UserField.IdAndName => id.Length > 0 && name.Length > 0 ? $"{id}.{name}" : string.Empty, // 레거시(bzDataInterface.vb:17930)는 빈 값 검사 없이 <b>항상</b> 결합한다 —
// 한쪽이 비면 "id." 나 ".이름" 이 그대로 나간다. 조건을 붙였더니 레거시와 갈렸다.
UserField.IdAndName => $"{id}.{name}",
UserField.MobilePhone => mobile, UserField.MobilePhone => mobile,
UserField.OfficePhone => office, 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, _ => string.Empty,
}; };
} }
private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, Month, Day, Hour, Minute, Time } private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, YearSuffixed, Month, Day, Hour, Minute, Time }
private enum UserField { Name, Id, IdAndName, MobilePhone, OfficePhone } private enum UserField { Name, Id, IdAndName, MobilePhone, OfficePhone, License, Specialist, Job, Department, DepartmentAndName, DepPhone }
#endregion #endregion
} }
@@ -14,6 +14,17 @@ public static class LegacyTypeCatalog
/// <summary>페이지 루트 클래스 단축명</summary> /// <summary>페이지 루트 클래스 단축명</summary>
public const string HostClassName = "MDesignerHost"; 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> /// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
public const string HostAqn = public const string HostAqn =
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"; "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", ["MPictureBox"] = "PictureBox",
["MCalcBox"] = "CalcBox", ["MCalcBox"] = "CalcBox",
["MButton"] = "Button", ["MButton"] = "Button",
["MDataTable"] = "DataTable", ["MDataTable"] = DataTableType,
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록 // Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
["Spread"] = "Spread", ["Spread"] = "Spread",
}; };
+14
View File
@@ -43,6 +43,20 @@ public sealed class DataConfig
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다. /// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
/// </summary> /// </summary>
public string DevUidCod { get; set; } = string.Empty; 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 #endregion
#region Methods #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> /// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
private static string? ResolveIniPath(string? iniPath) 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
}
@@ -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
}
+104 -31
View File
@@ -26,7 +26,26 @@ public sealed record PatientSummary(
/// <summary>내원 1건</summary> /// <summary>내원 1건</summary>
public sealed record VisitSummary( public sealed record VisitSummary(
decimal ComNum, string Kind, string AcceptedAt, string LeftAt, decimal ComNum, string Kind, string AcceptedAt, string LeftAt,
string Department, string Doctor, string Insurance, string State); 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> /// <summary>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회. /// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
@@ -97,26 +116,22 @@ public sealed class PatientVisitStore
using var connection = Open(); using var connection = Open();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.BindByName = true; command.BindByName = true;
// 처음에는 최종내원일시와 재원여부를 <b>파생표 두 개</b>로 붙였다.
// 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로,
// 조건에 맞는 환자가 몇 명이든 <b>내원 테이블 전체</b>를 집계했다 — 실측 5,025ms.
//
// 최종내원일시는 <b>화면에 쓰지도 않았다</b>(목록 열은 차트번호·성명·주민번호·휴대전화·비고).
// 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다.
//
// 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 —
// 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b> 따로 읽는다(아래 FillInpatient).
command.CommandText = command.CommandText =
"SELECT * FROM (" "SELECT * FROM ("
+ " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam," + " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam,"
+ " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked," + " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked,"
+ " NVL(PatMblPhn,' ') Mbl," + " NVL(PatMblPhn,' ') Mbl,"
+ " NVL(B.LastVisit,' ') LastVisit,"
+ " NVL(I.ComNum, 0) InP,"
+ " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon" + " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon"
+ " FROM P_PatInf A" + " FROM P_PatInf"
// 최종 내원일시 — 레거시와 같은 서브쿼리(취소 내원 제외)
+ " LEFT JOIN (SELECT ComChtNum, MAX(ComAcpDtm) LastVisit FROM P_ComInf"
+ " WHERE ComAcpStt <> 'OC' GROUP BY ComChtNum) B"
+ " ON A.PatChtNum = B.ComChtNum"
// 지금 재원 중인가 — 레거시는 오늘 날짜를 클라이언트에서 넣지만 여기서는 DB 시각을 쓴다
+ " LEFT JOIN (SELECT ComChtNum, MIN(ComNum) ComNum FROM P_ComInf"
+ " WHERE ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm"
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'"
+ " GROUP BY ComChtNum) I"
+ " ON A.PatChtNum = I.ComChtNum"
+ $" WHERE {where}" + $" WHERE {where}"
+ " ORDER BY PatNam, PatChtNum" + " ORDER BY PatNam, PatChtNum"
+ $") WHERE ROWNUM <= {max}"; + $") WHERE ROWNUM <= {max}";
@@ -131,12 +146,64 @@ public sealed class PatientVisitStore
reader.GetString(1).Trim(), reader.GetString(1).Trim(),
reader.GetString(2).Trim(), reader.GetString(2).Trim(),
reader.GetString(3).Trim(), reader.GetString(3).Trim(),
reader.GetString(4).Trim(), // 최종내원일시는 더 이상 읽지 않는다 — 화면에 없는 값이었고 집계가 5초였다
reader.GetDecimal(5) > 0, string.Empty,
reader.GetString(6).Trim() == "Y", false,
reader.GetString(7).Trim() != "N")); reader.GetString(4).Trim() == "Y",
reader.GetString(5).Trim() != "N"));
} }
return result; 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> /// <summary>
@@ -164,24 +231,30 @@ public sealed class PatientVisitStore
+ " SELECT A.ComNum," + " SELECT A.ComNum,"
+ " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind," + " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind,"
+ " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt," + " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt,"
+ " NVL(Dep.Nam,' ') Dep, NVL(Doc.Nam,' ') Dtr, NVL(Ins.Nam,' ') Ins," + " 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" + " NVL(Stt.DtlCodNam,' ') Stt"
+ " FROM P_ComInf A" + " FROM P_ComInf A"
+ " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0" + " 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" + " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0"
+ " LEFT JOIN (SELECT TRIM(DepCod) Cod, DepKorNam Nam," // 처음에는 마스터 셋을 ROW_NUMBER 파생표로 접었다. 기간 PK 중복은 해결되지만
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(DepCod) ORDER BY DepStrDte DESC) Rn" // <b>마스터 전체를 정렬</b>한 뒤에야 조인되므로 내원이 한 건인 환자도 5초가 걸렸다(실측).
+ " FROM M_DepMst) Dep ON Dep.Cod = TRIM(B.CodDepCod) AND Dep.Rn = 1" // 상관 스칼라로 바꾼다 — 내원 행마다 코드 하나를 인덱스로 찾는다.
+ " LEFT JOIN (SELECT TRIM(UidCod) Cod, UidNam Nam," // MAX(...) KEEP (DENSE_RANK FIRST ORDER BY 시작일 DESC) 가 "가장 최근 것 하나"를
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(UidCod) ORDER BY UidStrDte DESC) Rn" // 정렬 없이 뽑아 주고, 결과는 파생표 방식과 같다(중복도 생기지 않는다).
+ " FROM M_UidMst) Doc ON Doc.Cod = TRIM(B.CodDtrCod) AND Doc.Rn = 1"
+ " LEFT JOIN (SELECT TRIM(InsCod) Cod, InsNam Nam,"
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(InsCod) ORDER BY InsStrDte DESC) Rn"
+ " FROM M_InsMst) Ins ON Ins.Cod = TRIM(C.CoiInsCod) AND Ins.Rn = 1"
+ " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt" + " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt"
+ " WHERE TRIM(A.ComChtNum) = :c" // 컬럼에 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" + " ORDER BY A.ComAcpDtm DESC"
+ $") WHERE ROWNUM <= {max}"; + $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("p", key + "%"));
command.Parameters.Add(new OracleParameter("c", key)); command.Parameters.Add(new OracleParameter("c", key));
LastSql = command.CommandText; LastSql = command.CommandText;
@@ -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
}
+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
}
+23 -3
View File
@@ -72,21 +72,41 @@ public sealed class TagPreviewStore
} }
} }
/// <summary>연락처 두 칸 — 세션에 없는 것은 이것뿐이라 이 한 줄만 더 읽는다</summary> /// <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) private static (string Mobile, string Office) ReadPhones(OracleConnection connection, string uidCod)
{ {
if (uidCod.Length == 0) if (uidCod.Length == 0)
{ {
return (string.Empty, string.Empty); 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(); using var command = connection.CreateCommand();
command.CommandText = "SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid"; command.CommandText = sql;
command.CommandTimeout = TimeoutSeconds; command.CommandTimeout = TimeoutSeconds;
command.Parameters.Add(new OracleParameter("uid", uidCod)); command.Parameters.Add(new OracleParameter("uid", uidCod));
using var reader = command.ExecuteReader(); using var reader = command.ExecuteReader();
if (!reader.Read()) if (!reader.Read())
{ {
return (string.Empty, string.Empty); return null;
} }
return (reader.IsDBNull(0) ? string.Empty : reader.GetString(0), return (reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1)); reader.IsDBNull(1) ? string.Empty : reader.GetString(1));
+37 -3
View File
@@ -9,7 +9,25 @@ namespace SheetMe.Data.Stores;
public sealed record HisUser( public sealed record HisUser(
string UidCod, string UidNam, string UidCod, string UidNam,
string DepCod, string DepNam, 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> /// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); 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(); connection.Open();
using var command = connection.CreateCommand(); using var command = connection.CreateCommand();
command.BindByName = true; command.BindByName = true;
// 레거시와 두 가지가 <b>의도적으로</b> 다르다(워크플로 검증이 짚었고, 받아들이기로 했다):
// · 유효기간 판정 일자 — 레거시는 로그인 시점(GetAdpDtm 앞 8자, clsCommonLib.vb:10622),
// 여기는 SYSDATE. 자정을 넘겨 계속 켜 둔 세션에서만 갈린다.
// · Trim — 레거시는 UidCod/UidNam 을 무가공으로 쓰고(bzDataInterface.vb:16713, 16720)
// 여기는 걷는다. CHAR 잔여 공백이 종이에 찍히는 쪽이 더 이상하다고 판단했다.
command.CommandText = command.CommandText =
"SELECT u.UidCod, NVL(u.UidNam,' '), " + "SELECT u.UidCod, NVL(u.UidNam,' '), " +
" NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " + " 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 " + "FROM M_UidMst u " +
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " + "LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " + " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
"LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " + "LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " + " 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 " + "WHERE u.UidCod = :u " +
"AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte"; "AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
command.Parameters.Add(new OracleParameter("u", uidCod.Trim())); command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
@@ -79,7 +109,11 @@ public sealed class UserContextStore
return new HisUser( return new HisUser(
reader.GetString(0).Trim(), reader.GetString(1).Trim(), reader.GetString(0).Trim(), reader.GetString(1).Trim(),
reader.GetString(2).Trim(), reader.GetString(3).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 #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
}
+6
View File
@@ -223,6 +223,12 @@ public partial class App : Application
return Diagnostics.DbSmoke.RunSample(args[1], args[2]); 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") if (args.Length >= 3 && args[0] == "--db-word-smoke")
{ {
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]); return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
@@ -50,6 +50,13 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
switch (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.Left: designer.NudgeSelection(-step, 0); break;
case Key.Right: designer.NudgeSelection(step, 0); break; case Key.Right: designer.NudgeSelection(step, 0); break;
case Key.Up: designer.NudgeSelection(0, -step); break; case Key.Up: designer.NudgeSelection(0, -step); break;
@@ -68,11 +75,29 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
case Key.X when ctrl: designer.CutSelection(); break; case Key.X when ctrl: designer.CutSelection(); break;
case Key.V when ctrl: designer.Paste(); break; case Key.V when ctrl: designer.Paste(); break;
case Key.D when ctrl: designer.CopySelection(); 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.Z when ctrl: designer.Undo.Undo(); break;
case Key.Y when ctrl: designer.Undo.Redo(); break; case Key.Y when ctrl: designer.Undo.Redo(); break;
case Key.G when ctrl && shift: designer.UngroupSelection(); break; case Key.G when ctrl && shift: designer.UngroupSelection(); break;
case Key.G when ctrl: designer.GroupSelection(); 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단 메뉴 안에만 있었다. // 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다. // 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다. // 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
@@ -21,6 +21,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove += OnMouseMove; AssociatedObject.MouseMove += OnMouseMove;
AssociatedObject.MouseLeftButtonUp += OnMouseUp; AssociatedObject.MouseLeftButtonUp += OnMouseUp;
AssociatedObject.LostMouseCapture += OnLostCapture; AssociatedObject.LostMouseCapture += OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown;
} }
protected override void OnDetaching() protected override void OnDetaching()
@@ -29,6 +30,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
AssociatedObject.MouseMove -= OnMouseMove; AssociatedObject.MouseMove -= OnMouseMove;
AssociatedObject.MouseLeftButtonUp -= OnMouseUp; AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
AssociatedObject.LostMouseCapture -= OnLostCapture; AssociatedObject.LostMouseCapture -= OnLostCapture;
AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown;
base.OnDetaching(); base.OnDetaching();
} }
@@ -54,6 +56,44 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
e.Handled = true; 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) private void OnMouseMove(object sender, MouseEventArgs e)
{ {
if (Designer is not { } designer) if (Designer is not { } designer)
@@ -138,6 +138,58 @@ public static class LayerListBehavior
{ {
syncing.Remove(list); 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> /// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
@@ -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"/> <Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
</DataTemplate> </DataTemplate>
<!-- 이미지 --> <!-- 이미지 — 해석기가 파일을 찾았으면 그 이미지를, 아니면 자리표시자를 그린다.
종이(미리보기·인쇄)에서는 자리표시자 테두리가 보이면 안 되므로 이미지가 있으면
테두리 없이 이미지만 남긴다. -->
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}"> <DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False"> <Grid>
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0" <Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
HorizontalAlignment="Center" VerticalAlignment="Center" <Border.Style>
TextTrimming="CharacterEllipsis"/> <Style TargetType="Border">
</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> </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 }; 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) private static void Paint(Path path, DependencyProperty property, Brush? color, string? brushKey)
{ {
if (brushKey is not null) if (brushKey is not null)
@@ -175,7 +184,12 @@ public static class LucideIcons
path.SetResourceReference(property, brushKey); path.SetResourceReference(property, brushKey);
return; 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) private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
@@ -131,6 +131,21 @@ internal sealed class FormDesignDataBusiness
public static string ModifyDenyReason() => ModifyPolicy.DenyReason( public static string ModifyDenyReason() => ModifyPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate); 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> /// <summary>
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다. /// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다. /// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
@@ -47,6 +47,48 @@ internal static class ClearTypeCheck
var window = BuildProbe(out var samples, out var popups); var window = BuildProbe(out var samples, out var popups);
window.Show(); 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 직후 찍으면 빈 화면을 잰다. // 창이 보이는 것과 합성기가 그려 낸 것은 다르다 — Loaded 직후 찍으면 빈 화면을 잰다.
// (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정) // (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정)
var timer = new System.Windows.Threading.DispatcherTimer var timer = new System.Windows.Threading.DispatcherTimer
@@ -84,6 +126,56 @@ internal static class ClearTypeCheck
return failures == 0 ? 0 : 1; 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> /// <summary>대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다</summary>
private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples) private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples)
{ {
File diff suppressed because it is too large Load Diff
@@ -130,6 +130,12 @@ public static class DialogShots
// 'DB에서 열기' 대화상자를 지웠다. 그 자리를 좌측 서식 목록 샷이 대신한다 — // 'DB에서 열기' 대화상자를 지웠다. 그 자리를 좌측 서식 목록 샷이 대신한다 —
// 안 그러면 서식명 잘림을 잡을 자리가 게이트에서 통째로 사라진다. // 안 그러면 서식명 잘림을 잡을 자리가 게이트에서 통째로 사라진다.
("01-sheet-list", () => NewMainShell(1440, 920, leftTab: 0, sheets: SampleSheets())), ("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())), ("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
("03-query-editor", () => NewQueryEditor(designer, ("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")), "SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
@@ -149,6 +155,12 @@ public static class DialogShots
("06-font-manager", () => new Views.FontManagerDialogView(designer)), ("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")), ("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)), ("08-preview", () => new Views.PreviewWindow(designer)),
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
// 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
// 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다. // 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다. // 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")), ("09-inspector-label", () => HostInspector(designer, "Label1")),
@@ -220,7 +232,7 @@ public static class DialogShots
/// 빈 껍데기를 찍으면 잘림을 볼 수 없다. /// 빈 껍데기를 찍으면 잘림을 볼 수 없다.
/// </summary> /// </summary>
private static Window NewMainShell(double width, double height, int leftTab = 1, private static Window NewMainShell(double width, double height, int leftTab = 1,
List<SheetSummary>? sheets = null) List<SheetSummary>? sheets = null, bool withDocument = true)
{ {
var window = new Views.MainView var window = new Views.MainView
{ {
@@ -234,7 +246,10 @@ public static class DialogShots
{ {
return window; return window;
} }
shell.NewFileCommand?.Execute(null); if (withDocument)
{
shell.AttachBlankForDiagnostics();
}
shell.SelectedLeftTabIndex = leftTab; shell.SelectedLeftTabIndex = leftTab;
// 진단 모드는 Loaded 커맨드를 안 돌리므로 목록이 비어 있다 — // 진단 모드는 Loaded 커맨드를 안 돌리므로 목록이 비어 있다 —
// 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다. // 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다.
@@ -375,18 +390,26 @@ public static class DialogShots
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다 // 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
Pump(); Pump();
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고, // <b>창 전체</b>를 찍는다 — Content 만 찍으면 타이틀바가 통째로 빠진다.
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다). //
// 전에는 Content 를 찍었다. 그래서 창 크롬을 통일하기 전까지 이 진단은
// "열한 창이 윈도우 기본 타이틀바를 달고 뜬다"는 사실을 <b>한 번도 보여 준 적이 없었다</b> —
// 고쳐야 할 바로 그 부분에 눈이 없었던 셈이다. 이제 커스텀 타이틀바는 창의 Template
// 안에 있으므로 Content 만 찍으면 여전히 안 보인다.
//
// 배경을 먼저 칠하는 것은 그대로 둔다 — 투명 픽셀이 PNG 에서 검정으로 저장되면
// 라이트 테마 결함이 "다크처럼 보이는" 착시에 가려진다.
var content = (FrameworkElement)window.Content; var content = (FrameworkElement)window.Content;
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width); var root = (FrameworkElement?)window.Template?.FindName("PART_Root", window) ?? content;
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height); 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 bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
var drawing = new DrawingVisual(); var drawing = new DrawingVisual();
using (var context = drawing.RenderOpen()) using (var context = drawing.RenderOpen())
{ {
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height)); 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)); null, new Rect(0, 0, width, height));
} }
bitmap.Render(drawing); bitmap.Render(drawing);
@@ -421,6 +444,33 @@ public static class DialogShots
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"), 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() private static List<DesignVersionInfo> SampleVersions() => new()
{ {
new DesignVersionInfo(52443, "202608121030", "011825", false), new DesignVersionInfo(52443, "202608121030", "011825", false),
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,15 @@ public static class ConfigLoader
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty; config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
config.GridSize = ReadInt(root["Designer:GridSize"], 4); config.GridSize = ReadInt(root["Designer:GridSize"], 4);
config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6); 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; return config;
} }
@@ -42,6 +42,12 @@ public sealed class InteractionController
private Rect groupBounds0; private Rect groupBounds0;
private readonly Dictionary<ControlViewModel, Rect> origBounds = new(); private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
private bool undoCaptured; private bool undoCaptured;
/// <summary>
/// 이번 드래그가 Ctrl+복사로 <b>사본을 만들었는가</b> — Esc 취소가 좌표만 되돌리면 안 되기 때문이다.
/// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다.
/// </summary>
private bool duplicatedThisDrag;
#endregion #endregion
#region Constructors #region Constructors
@@ -156,8 +162,29 @@ public sealed class InteractionController
if (!downControl.IsSelected) if (!downControl.IsSelected)
{ {
var mates = designer.GroupMatesOf(downControl); 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(); BeginMove();
DoMove(world, ctx); // 전환 프레임부터 즉시 추종 DoMove(world, ctx); // 전환 프레임부터 즉시 추종
} }
@@ -206,20 +233,21 @@ public sealed class InteractionController
designer.ReassignPagesAfterMove(origBounds.Keys.ToList()); designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
designer.Overlay.ClearGuides(); designer.Overlay.ClearGuides();
designer.Snap.EndDrag(); designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break; break;
case Mode.Resize: case Mode.Resize:
designer.Overlay.ClearGuides(); designer.Overlay.ClearGuides();
designer.Snap.EndDrag(); designer.Snap.EndDrag();
designer.Overlay.ClearDragBadge();
break; break;
case Mode.Marquee: case Mode.Marquee:
var rect = RectFrom(downPoint, world); var rect = RectFrom(downPoint, world);
designer.Overlay.UpdateMarquee(null); designer.Overlay.UpdateMarquee(null);
var hits = designer.ControlsIntersecting(rect) // 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
.SelectMany(designer.GroupMatesOf) // GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
.Distinct() var hits = designer.MarqueeHits(rect);
.ToList();
if (ctx.Ctrl || ctx.Shift) if (ctx.Ctrl || ctx.Shift)
{ {
var merged = designer.Selection.Items.Union(hits).ToList(); var merged = designer.Selection.Items.Union(hits).ToList();
@@ -235,6 +263,7 @@ public sealed class InteractionController
mode = Mode.None; mode = Mode.None;
downControl = null; downControl = null;
origBounds.Clear(); origBounds.Clear();
duplicatedThisDrag = false; // 이 드래그는 끝났다 — 다음 Esc 가 남의 복제를 물면 안 된다
designer.RefreshOverlay(); designer.RefreshOverlay();
} }
@@ -246,6 +275,29 @@ public sealed class InteractionController
{ {
if (mode is Mode.Move or Mode.Resize) 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) foreach (var (vm, rect) in origBounds)
{ {
var offset = designer.ParentWorldOffset(vm); var offset = designer.ParentWorldOffset(vm);
@@ -393,6 +445,11 @@ public sealed class InteractionController
} }
designer.Selection.NotifyBoundsChanged(); designer.Selection.NotifyBoundsChanged();
// 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
if (designer.SelectionWorldBounds() is { } movedBounds)
{
designer.Overlay.SetDragBadge(movedBounds, resizing: false);
}
} }
private void DoResize(Point world, PointerContext ctx) private void DoResize(Point world, PointerContext ctx)
@@ -459,6 +516,10 @@ public sealed class InteractionController
} }
designer.Selection.NotifyBoundsChanged(); designer.Selection.NotifyBoundsChanged();
if (designer.SelectionWorldBounds() is { } sizedBounds)
{
designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
}
} }
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary> /// <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
}
@@ -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
+133 -24
View File
@@ -16,20 +16,46 @@ public static class PrintService
{ {
#region Methods #region Methods
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary> /// <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(); 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) if (dialog.ShowDialog() != true)
{ {
return; return;
} }
var document = BuildFixedDocument(designer); var document = BuildFixedDocument(designer, tags, fields, checks);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}"); dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
} }
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary> /// <summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer) /// 페이지 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(); var document = new FixedDocument();
foreach (var page in designer.Pages) foreach (var page in designer.Pages)
@@ -40,7 +66,9 @@ public static class PrintService
Height = page.HeightDip, Height = page.HeightDip,
Background = page.PaperBrush, 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(); var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage); ((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -59,7 +87,9 @@ public static class PrintService
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다. /// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
/// </summary> /// </summary>
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true, public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
SheetMe.Core.Catalog.ITagValueResolver? tags = null) SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
var canvas = new Canvas var canvas = new Canvas
{ {
@@ -91,7 +121,7 @@ public static class PrintService
} }
var presenter = new ContentPresenter var presenter = new ContentPresenter
{ {
Content = PrintProjection(control, printFilter, tags), Content = PrintProjection(control, printFilter, tags, fields, checks),
Width = Math.Max(1, control.Width), Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height), Height = Math.Max(1, control.Height),
}; };
@@ -121,11 +151,13 @@ public static class PrintService
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b> /// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
/// </summary> /// </summary>
private static object PrintProjection(ControlViewModel control, bool printFilter, private static object PrintProjection(ControlViewModel control, bool printFilter,
SheetMe.Core.Catalog.ITagValueResolver? tags) SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
if (control is not ContainerViewModel container) if (control is not ContainerViewModel container)
{ {
return WithTagValue(control, tags); return WithTagValue(control, tags, fields, checks);
} }
ContainerViewModel? copy = container switch ContainerViewModel? copy = container switch
{ {
@@ -145,7 +177,7 @@ public static class PrintService
{ {
continue; continue;
} }
if (PrintProjection(child, printFilter, tags) is ControlViewModel projected) if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
{ {
copy.Children.Add(projected); copy.Children.Add(projected);
} }
@@ -165,28 +197,105 @@ public static class PrintService
/// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다. /// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다.
/// 타입이 같으니 템플릿이 그대로 잡히고, <b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b> /// 타입이 같으니 템플릿이 그대로 잡히고, <b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b>
/// ///
/// <b>못 만든 값은 감추지 않는다.</b> 사유를 대괄호로 감싸 그린다 — /// <b>못 만든 값은 빈칸이다</b>(2026-08-20 사용자 결정). 처음에는 사유를 대괄호로
/// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다. /// 감싸 그렸는데("못 만든 것을 감추지 않는다"), 실사용 출력물에 [태그 — 사유] 가
/// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다). /// 찍히면 안 되므로 값이 안 되는 태그는 종이·미리보기 모두 빈칸으로 바꾼다 —
/// 태그 이름을 그대로 두면 값이 나온 줄 알기 때문에 이름도 지운다.
/// 사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
/// </summary> /// </summary>
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags) private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
SheetMe.Core.Catalog.IDataFieldResolver? fields,
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
{ {
if (tags is 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))
{ {
return control; clicked = state;
} }
var tag = control.Model.Props.GetText("DataInterfaceTag"); // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
if (tag is not { Length: > 0 } || tag == "None") // 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields);
if (label is null)
{ {
return control; 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 value = tags.Resolve(tag);
var text = value.Resolved ? value.Text
: value.Text.Length > 0 ? $"[{tag} — {value.Text}]" : $"[{tag}]";
var model = control.Model.Clone(); var model = control.Model.Clone();
model.Props.SetText("Text", text); 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); 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 #endregion
} }
@@ -58,7 +58,10 @@ public sealed class SessionTagResolver : ITagValueResolver
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
? TagPreviewCatalog.FromClock(tag, ready.Minute12) ? TagPreviewCatalog.FromClock(tag, ready.Minute12)
: TagPreviewCatalog.FromUser(tag, ready.UidCod, ready.UidNam, : TagPreviewCatalog.FromUser(tag, ready.UidCod, ready.UidNam,
ready.MobilePhone, ready.OfficePhone); ready.MobilePhone, ready.OfficePhone,
UserSession.Current.LicenseNumber, UserSession.Current.SpecialistNumber,
UserSession.Current.JobName, UserSession.Current.DepNam,
UserSession.Current.DepPhone);
return value.Length > 0 return value.Length > 0
? new TagValue(true, value) ? new TagValue(true, value)
: new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다"); : new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다");
+37 -1
View File
@@ -19,6 +19,9 @@ public sealed class UndoService
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary> /// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
private int savedDepth; private int savedDepth;
/// <summary>마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다</summary>
private bool lastDirty;
#endregion #endregion
#region Properties #region Properties
@@ -47,6 +50,12 @@ public sealed class UndoService
#region Events #region Events
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary> /// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
public event Action? Restored; public event Action? Restored;
/// <summary>
/// <see cref="IsDirty"/> 값이 <b>바뀌었을 때만</b> 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다.
/// 매번 통지하면 드래그 한 번에 수백 번 울린다.
/// </summary>
public event Action? DirtyChanged;
#endregion #endregion
#region Methods #region Methods
@@ -72,10 +81,15 @@ public sealed class UndoService
} }
redoStack.Clear(); redoStack.Clear();
lastNudgeAt = DateTime.MinValue; lastNudgeAt = DateTime.MinValue;
RaiseDirtyIfChanged();
} }
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary> /// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
public void MarkSaved() => savedDepth = undoStack.Count; public void MarkSaved()
{
savedDepth = undoStack.Count;
RaiseDirtyIfChanged();
}
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary> /// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
public void SnapshotForNudge() public void SnapshotForNudge()
@@ -100,6 +114,27 @@ public sealed class UndoService
undoStack.RemoveAt(undoStack.Count - 1); undoStack.RemoveAt(undoStack.Count - 1);
restoreDocument(snapshot); restoreDocument(snapshot);
Restored?.Invoke(); 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> /// <summary>다시 실행</summary>
@@ -114,6 +149,7 @@ public sealed class UndoService
redoStack.RemoveAt(redoStack.Count - 1); redoStack.RemoveAt(redoStack.Count - 1);
restoreDocument(snapshot); restoreDocument(snapshot);
Restored?.Invoke(); Restored?.Invoke();
RaiseDirtyIfChanged();
} }
#endregion #endregion
} }
+108 -9
View File
@@ -28,6 +28,18 @@
<Setter Property="FontFamily" Value="Malgun Gothic" /> <Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="13" /> <Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" /> <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> </Style>
<!-- 순수 Window 인스턴스용(파생 클래스에는 위 키 스타일을 직접 걸어야 한다) --> <!-- 순수 Window 인스턴스용(파생 클래스에는 위 키 스타일을 직접 걸어야 한다) -->
@@ -37,7 +49,12 @@
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" /> <Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" /> <Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" /> <!--
TextFormattingMode 을 여기서 정하지 않는다. <b>스타일 세터는 상속값을 이긴다</b> —
여기에 값을 박으면 종이 안의 글자까지 그 값이 되어 용지 Grid 가 정한 Ideal 을 덮는다.
전에 Ideal 이 박혀 있어서 화면 UI 글자가 전부 흐렸다.
지금은 창(Display)과 종이(Ideal)가 각각 상속으로 정한다.
-->
</Style> </Style>
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== --> <!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
@@ -55,7 +72,10 @@
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> 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> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -98,7 +118,10 @@
<ControlTemplate TargetType="Button"> <ControlTemplate TargetType="Button">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> 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> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <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="Background" Value="{DynamicResource B.AccentFillPressed}" />
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" /> <Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" />
</Trigger> </Trigger>
<!--
비활성은 <b>색을 빼서</b> 알린다. 전에는 불투명도만 0.45 로 내렸는데,
그러면 강조색이 옅어질 뿐 여전히 파랗고 그 위의 흰 글자가 배경에 묻힌다
(환자 선택 창의 '선택' 버튼이 그 상태로 뜬다 — 처음부터 비활성이다).
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"를 한꺼번에 없앤다.
-->
<Trigger Property="IsEnabled" Value="False"> <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> </Trigger>
</ControlTemplate.Triggers> </ControlTemplate.Triggers>
</ControlTemplate> </ControlTemplate>
@@ -647,11 +678,12 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0"> BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled"> <ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom"> <StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
<!--
탭 흐름만 둔다. 전에는 옆에 '새 서식 탭(+)' 버튼이 있었으나
서식은 반드시 목록에서 골라 연다 — 코드 없는 빈 문서는
DB 에 저장할 수 없어서, 만들 수 있게 두면 그린 뒤에 막힌다.
-->
<StackPanel Orientation="Horizontal" IsItemsHost="True" /> <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> </StackPanel>
</ScrollViewer> </ScrollViewer>
</Border> </Border>
@@ -721,7 +753,10 @@
<ControlTemplate TargetType="ToggleButton"> <ControlTemplate TargetType="ToggleButton">
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}" <Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True"> 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> <ContentPresenter.Resources>
<Style TargetType="TextBlock"> <Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" /> <Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
@@ -1386,4 +1421,68 @@
</Style.Triggers> </Style.Triggers>
</Style> </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> </ResourceDictionary>
@@ -199,6 +199,33 @@ public abstract class ControlViewModel : ViewModelBase, ILayerRow
/// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary> /// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary>
public bool HasLayerId => LayerId.Length > 0; 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> /// <summary>레이어 잠금 토글 아이콘명</summary>
public string LockIcon => Model.Locked ? "lock" : "unlock"; public string LockIcon => Model.Locked ? "lock" : "unlock";
@@ -136,6 +136,30 @@ public abstract class ContainerViewModel : ControlViewModel
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary> /// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
public override bool HasLayerChildren => Children.Count > 0; 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) { } protected ContainerViewModel(ControlElement model) : base(model) { }
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary> /// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
@@ -180,6 +204,59 @@ public sealed class PictureBoxViewModel : ControlViewModel
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary> /// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
public string Hint => Prop("DataInterfaceTag") ?? "이미지"; 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) { } public PictureBoxViewModel(ControlElement model) : base(model) { }
} }
@@ -129,6 +129,21 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary> /// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary>
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName)); 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> /// <summary>페이지 VM 목록</summary>
public ObservableCollection<PageViewModel> Pages { get; } = new(); public ObservableCollection<PageViewModel> Pages { get; } = new();
@@ -290,6 +305,8 @@ public sealed class DesignerViewModel : ViewModelBase
ScreenTolerance = ConfigService.Current.SnapThreshold, ScreenTolerance = ConfigService.Current.SnapThreshold,
}; };
Undo = new UndoService(() => Document, ReplaceDocument); Undo = new UndoService(() => Document, ReplaceDocument);
// 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다
Undo.DirtyChanged += NotifyDirtyChanged;
Interaction = new InteractionController(this); Interaction = new InteractionController(this);
Inspector = new InspectorViewModel(this); Inspector = new InspectorViewModel(this);
Selection.Changed += RefreshOverlay; Selection.Changed += RefreshOverlay;
@@ -314,6 +331,15 @@ public sealed class DesignerViewModel : ViewModelBase
UngroupCommand = new Command((sender, e) => UngroupSelection()); UngroupCommand = new Command((sender, e) => UngroupSelection());
CutCommand = new Command((sender, e) => CutSelection()); CutCommand = new Command((sender, e) => CutSelection());
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both")); 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)); SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty));
CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H")); CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H"));
TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode()); TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode());
@@ -371,6 +397,15 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary> /// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
public ICustomCommand? SizeToControlCommand { get; set; } 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> /// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
public ICustomCommand? SpacingCommand { get; set; } public ICustomCommand? SpacingCommand { get; set; }
@@ -577,11 +612,58 @@ public sealed class DesignerViewModel : ViewModelBase
RefreshOverlay(); 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) public void ReplaceDocument(FormDocument newDocument)
{ {
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
var primaryId = Selection.Primary?.Id;
document = newDocument; document = newDocument;
RebuildFromDocument(); 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> /// <summary>페이지 세로 오프셋 재계산</summary>
@@ -717,6 +799,37 @@ public sealed class DesignerViewModel : ViewModelBase
return HitInList(page.Controls, local); 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) private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local)
{ {
for (var i = controls.Count - 1; i >= 0; i--) for (var i = controls.Count - 1; i >= 0; i--)
@@ -751,30 +864,110 @@ public sealed class DesignerViewModel : ViewModelBase
var result = new List<ControlViewModel>(); var result = new List<ControlViewModel>();
foreach (var page in Pages) foreach (var page in Pages)
{ {
foreach (var vm in page.Controls) CollectIntersecting(page.Controls, worldRect, result);
{
if (vm.Model.Hidden || vm.Model.Locked)
{
continue;
}
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
{
result.Add(vm);
}
}
} }
return 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> /// <summary>오버레이(선택 박스/핸들) 재계산</summary>
public void RefreshOverlay() public void RefreshOverlay()
{ {
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0); Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
RefreshLockMarks();
if (Selection.Primary is not null) if (Selection.Primary is not null)
{ {
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage; 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 #endregion
#region Events #region Events
@@ -1413,6 +1606,71 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows(); 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> /// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap) private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
{ {
@@ -1588,6 +1846,156 @@ public sealed class DesignerViewModel : ViewModelBase
Selection.NotifyBoundsChanged(); 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> /// <summary>활성 페이지 전체 선택</summary>
public void SelectAllOnActivePage() public void SelectAllOnActivePage()
{ {
@@ -1769,8 +2177,13 @@ public sealed class DesignerViewModel : ViewModelBase
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1; => Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
/// <summary> /// <summary>
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록. /// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다). /// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
///
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
/// 즉 이 조작이 "문항 만들기" 다.
/// </summary> /// </summary>
public void GroupSelection() public void GroupSelection()
{ {
@@ -1784,6 +2197,15 @@ public sealed class DesignerViewModel : ViewModelBase
DialogService.Notify(DialogKind.Warning, "그룹", reason); DialogService.Notify(DialogKind.Warning, "그룹", reason);
return; return;
} }
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
if (RadioSplitWarning(page, targets) is { } warning
&& !DialogService.Confirm("그룹", warning,
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
{
return;
}
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다. // z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데, // WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
@@ -1837,15 +2259,7 @@ public sealed class DesignerViewModel : ViewModelBase
/// </summary> /// </summary>
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets) 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(); var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
if (indices.Any(i => i < 0)) if (indices.Any(i => i < 0))
{ {
@@ -1860,6 +2274,32 @@ public sealed class DesignerViewModel : ViewModelBase
return null; 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; private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
/// <summary> /// <summary>
@@ -2220,6 +2660,19 @@ public sealed class DesignerViewModel : ViewModelBase
=> total.TryGetValue(id, out var t) && t > 0 => total.TryGetValue(id, out var t) && t > 0
&& selected.TryGetValue(id, out var s) && s == t; && 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]; var states = new LayerHighlight[LayerRows.Count];
for (var i = 0; i < LayerRows.Count; i++) for (var i = 0; i < LayerRows.Count; i++)
{ {
@@ -2229,6 +2682,12 @@ public sealed class DesignerViewModel : ViewModelBase
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy, ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
ControlViewModel c when c.IsSelected => LayerHighlight.Blue, ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue, LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
// 선택한 컨테이너 안에 있는 것 — 그룹 판정보다 <b>뒤에</b> 둔다(그룹 색을 이기면 안 된다).
//
// 캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
// 밴드가 붙으면 "이걸 지우면 무엇이 같이 지워지나"가 선택 즉시 보인다 —
// 접었다 펴 보지 않아도 된다.
ControlViewModel c when HasSelectedContainerAncestor(c) => LayerHighlight.Navy,
_ => LayerHighlight.None, _ => LayerHighlight.None,
}; };
} }
@@ -2321,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다 /// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary> /// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</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); public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
/// <summary>페이지 펼침/접기</summary> /// <summary>페이지 펼침/접기</summary>
@@ -388,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
var items = designer.Selection.Items; var items = designer.Selection.Items;
if (items.Count == 0) if (items.Count == 0)
{ {
RefreshTabCounts(null); RefreshTabCounts(Array.Empty<PropertyDef>(), 0);
if (designer.InspectedPage is { } page) if (designer.InspectedPage is { } page)
{ {
BuildPageRows(page); BuildPageRows(page);
@@ -397,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
return; return;
} }
// 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다 // 편집할 속성 집합을 <b>먼저</b> 정하고, 그 결과로 탭을 정한다.
var type = items[0].Type; //
var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null; // 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
RefreshTabCounts(descriptor); // 타입이 섞이면 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) if (selectedTab == InspectorTab.Design)
{ {
@@ -429,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
AddFontStyleSegment(); AddFontStyleSegment();
} }
// 타입 전용 — 선택 탭에 해당하는 속성만 // 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal); 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); 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) if (tabDefs.Count > 0)
{ {
AddSection(descriptor.DisplayName); // 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
// 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
AddSection(types.Count == 1 && descriptor is not null
? descriptor.DisplayName
: $"공통 속성 ({types.Count}종 타입)");
foreach (var def in tabDefs) 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)); OnPropertyChanged(nameof(IsTabEmpty));
@@ -469,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만 /// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다. /// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
/// </summary> /// </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); DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior); BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
@@ -548,7 +575,8 @@ public sealed class InspectorViewModel : ViewModelBase
// 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다 // 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
AddSection("기본 글꼴 (컨트롤이 상속)"); 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)", AddPageFontRow(page, "크기(pt)",
f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture), f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0 (f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0
@@ -646,9 +674,12 @@ public sealed class InspectorViewModel : ViewModelBase
return row; return row;
} }
/// <summary>페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다</summary> /// <summary>
/// 페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다.
/// <paramref name="isFamily"/> 면 이름 칸이라 설치 글꼴 목록에서 고를 수 있게 한다.
/// </summary>
private void AddPageFontRow(PageViewModel page, string label, 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; var root = page.Model.Root;
LegacyFont Current() LegacyFont Current()
@@ -657,7 +688,8 @@ public sealed class InspectorViewModel : ViewModelBase
return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont(); 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(); var font = Current();
set(font, value); set(font, value);
@@ -665,12 +697,41 @@ public sealed class InspectorViewModel : ViewModelBase
}, () => designer.RefreshPageInheritance(page)); }, () => designer.RefreshPageInheritance(page));
} }
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary> /// <summary>
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys) /// 고급 섹션 구성 — 접이식, 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 if (targets.Count == 0)
.Where(k => !curatedKeys.Contains(k)) {
.ToList(); 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) if (!showAdvanced)
{ {
@@ -692,6 +753,36 @@ public sealed class InspectorViewModel : ViewModelBase
foreach (var key in advancedKeys) 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); var value = target.Model.Props.Get(key);
switch (value) switch (value)
{ {
@@ -736,6 +827,13 @@ public sealed class InspectorViewModel : ViewModelBase
} }
} }
// 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 <b>하나</b> 기준이라
// 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
if (!single)
{
return;
}
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다). // 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 — // 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다. // 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
@@ -752,7 +850,16 @@ public sealed class InspectorViewModel : ViewModelBase
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys))); }, 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 var binding = def.Editor == PropEditorKind.StringList
? new RowBinding ? new RowBinding
@@ -769,6 +876,7 @@ public sealed class InspectorViewModel : ViewModelBase
} }
vm.Model.Props.Set(def.Key, itemsValue); vm.Model.Props.Set(def.Key, itemsValue);
}, },
ConfirmBatch = ConfirmDestructive(def.Key),
} }
: new RowBinding : new RowBinding
{ {
@@ -793,6 +901,7 @@ public sealed class InspectorViewModel : ViewModelBase
vm.Model.Props.SetText(def.Key, value); vm.Model.Props.SetText(def.Key, value);
} }
}, },
ConfirmBatch = ConfirmDestructive(def.Key),
}; };
PropertyRowViewModel row = def.Editor switch PropertyRowViewModel row = def.Editor switch
@@ -809,24 +918,109 @@ public sealed class InspectorViewModel : ViewModelBase
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label, PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags, "데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)), LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
// 액션은 값이 아니라 동작이다 — 피커가 데이터 태그용 값·사유 칸을 그리면 안 된다
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label, PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags, "액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags,
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)), LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)) { IsActionTag = true },
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label), // 컨트롤 이름을 넘긴다 — 안 넘기면 인스펙터로 연 편집기에서만 《컨트롤명》 칩이 사라져
// 같은 창이 진입 경로에 따라 능력이 달라진다
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label) { ControlNamesOf = ControlNames },
PropEditorKind.Mask => new MaskRowViewModel(def.Label), 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), _ => 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> /// <summary>
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지. /// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
/// ///
/// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고, /// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
/// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다. /// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
/// </summary> /// </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() private IReadOnlyList<string> DataTableNames()
{ {
var names = new List<string>(); var names = new List<string>();
@@ -905,6 +1099,8 @@ public sealed class InspectorViewModel : ViewModelBase
set(vm, Math.Round(number)); set(vm, Math.Round(number));
} }
}, },
// 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
AffectsVisual = false, AffectsVisual = false,
}; };
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged()); BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
@@ -912,9 +1108,16 @@ public sealed class InspectorViewModel : ViewModelBase
return row; return row;
} }
/// <summary>
/// 글꼴 이름 행 — 설치된 글꼴에서 고른다(직접 입력도 그대로 된다).
///
/// 전에는 자유 입력 칸 하나뿐이라 "돋움"을 매번 타이핑해야 했다. 그리고 한 글자만 틀려도
/// 레거시는 아무 말 없이 기본 글꼴로 떨어진다 — 디자이너에서는 멀쩡해 보이고
/// EMR 에서만 다른 글꼴로 나온다.
/// </summary>
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set) 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), Get = vm => get(vm.EffectiveFont),
Set = (vm, value) => Set = (vm, value) =>
@@ -941,6 +1144,10 @@ public sealed class InspectorViewModel : ViewModelBase
set(font, value); set(font, value);
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font)); vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
}, },
// 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
// "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
&& size > 0,
}); });
} }
@@ -975,6 +1182,16 @@ public sealed class InspectorViewModel : ViewModelBase
return new SegmentItem(icon, label, toggle); 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> /// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
private static LegacyFont CurrentFontOf(ControlViewModel vm) private static LegacyFont CurrentFontOf(ControlViewModel vm)
{ {
@@ -1030,6 +1247,12 @@ public sealed class InspectorViewModel : ViewModelBase
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다. // 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
if (row is ChoiceRowViewModel choice) if (row is ChoiceRowViewModel choice)
{ {
// 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
// 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
if (values.Count > 1)
{
choice.EnsureMixedPlaceholder();
}
choice.EnsureChoice(initial); choice.EnsureChoice(initial);
} }
@@ -1042,6 +1265,27 @@ public sealed class InspectorViewModel : ViewModelBase
{ {
return; 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회라 // 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다. // 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
if (row.CoalesceUndo) if (row.CoalesceUndo)
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary> /// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
public bool IsMixed { get; private set; } public bool IsMixed { get; private set; }
/// <summary>
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
///
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
/// </summary>
protected const string MixedSummary = "(여러 값)";
/// <summary> /// <summary>
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용. /// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼 /// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
get => valueText; get => valueText;
set set
{ {
// null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
// (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
// 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
// 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
value ??= string.Empty;
if (!SetProperty(ref valueText, value) || building) if (!SetProperty(ref valueText, value) || building)
{ {
return; return;
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
get => IsMixed ? null : ValueText == "True"; get => IsMixed ? null : ValueText == "True";
set 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)); 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> /// <summary>선택지 행</summary>
public sealed class ChoiceRowViewModel : PropertyRowViewModel public sealed class ChoiceRowViewModel : PropertyRowViewModel
{ {
@@ -402,6 +468,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
choices.Add(value); 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> /// <summary>
@@ -416,6 +503,29 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
/// <summary>피커 대화상자 제목</summary> /// <summary>피커 대화상자 제목</summary>
public string PickerTitle { get; } 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> /// <summary>
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지. /// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다. /// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
@@ -589,13 +699,16 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
if (e.PropertyName == nameof(ValueText)) if (e.PropertyName == nameof(ValueText))
{ {
OnPropertyChanged(nameof(ShowSuggestions)); OnPropertyChanged(nameof(ShowSuggestions));
// 값이 바뀌면 '카탈로그에 없음' 표식도 다시 판정한다
OnPropertyChanged(nameof(IsUnknownTag));
OnPropertyChanged(nameof(UnknownTagHint));
} }
}; };
} }
private void OnBrowse() 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, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -613,7 +726,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
public sealed class MaskRowViewModel : PropertyRowViewModel public sealed class MaskRowViewModel : PropertyRowViewModel
{ {
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary> /// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
public string Preview => ValueText.Length == 0 public string Preview => IsMixed
? MixedSummary
: ValueText.Length == 0
? "(마스크 없음)" ? "(마스크 없음)"
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText); : SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
@@ -648,6 +763,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
{ {
get get
{ {
if (IsMixed)
{
return MixedSummary;
}
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim(); var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine; return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
} }
@@ -663,9 +782,20 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary)); protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
/// <summary>
/// 이 서식의 컨트롤 이름 — 편집기의 《컨트롤명》 치환 후보가 된다.
///
/// 안 넘기면 <b>같은 창이 진입 경로에 따라 능력이 달라진다</b> —
/// 데이터소스 아이콘에서 열면 컨트롤 변수 칩이 나오는데
/// 인스펙터의 쿼리 행에서 열면 그 칩이 통째로 사라졌다.
/// 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
/// </summary>
public Func<IReadOnlyList<string>>? ControlNamesOf { get; init; }
private void OnEdit() 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, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -838,26 +968,56 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
{ {
private readonly Func<IReadOnlyList<string>> tablesOf; private readonly Func<IReadOnlyList<string>> tablesOf;
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary> /// <summary>
/// 사람이 읽는 요약. <b>'배선' 이라는 낱말을 쓰지 않는다</b> —
/// 전산실에서 배선은 랜선·전원이고, '배선 없음' 은 데이터소스가 없는 것처럼 읽힌다.
/// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같게</b> 둔다 —
/// 그 버튼을 누르면 무엇이 되는지 미리 보이게 하려는 것이다.
/// </summary>
public string Summary public string Summary
{ {
get get
{ {
if (IsMixed)
{
return MixedSummary;
}
if (ValueText.Length == 0) if (ValueText.Length == 0)
{ {
return "(배선 없음)"; return "(가져오지 않음)";
} }
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText); var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
return spec is null return spec is null
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다 // 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
? $"⚠ 형식을 알 수 없음: {ValueText}" ? $"⚠ 형식을 알 수 없음: {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> /// <summary>편집기 열기</summary>
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; } 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) public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
{ {
this.tablesOf = tablesOf; this.tablesOf = tablesOf;
@@ -873,7 +1033,8 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
private void OnEdit() 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, Owner = System.Windows.Application.Current.MainWindow,
}; };
@@ -987,4 +1148,22 @@ public sealed class RowBinding
/// <summary>커밋 후 시각 재해석 필요 여부</summary> /// <summary>커밋 후 시각 재해석 필요 여부</summary>
public bool AffectsVisual { get; init; } = true; 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; }
} }
@@ -206,7 +206,6 @@ internal sealed class MainViewModel : ViewModelBase
public MainViewModel() public MainViewModel()
{ {
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync()); LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile()); OpenFileCommand = new Command((sender, e) => OnOpenFile());
// Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 — // Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 —
// DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다. // DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다.
@@ -258,9 +257,6 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>창 로드</summary> /// <summary>창 로드</summary>
public ICustomCommand? LoadedCommand { get; set; } public ICustomCommand? LoadedCommand { get; set; }
/// <summary>새 서식</summary>
public ICustomCommand? NewFileCommand { get; set; }
/// <summary>서식 XML 열기</summary> /// <summary>서식 XML 열기</summary>
public ICustomCommand? OpenFileCommand { get; set; } public ICustomCommand? OpenFileCommand { get; set; }
@@ -497,8 +493,7 @@ internal sealed class MainViewModel : ViewModelBase
{ {
await LoadSheetListAsync(); await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다. // 기동 인자로 서식을 지정했으면 그것만 연다 — 레거시도 그렇다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
if (PendingSheetCode is { Length: > 0 } shtCod) if (PendingSheetCode is { Length: > 0 } shtCod)
{ {
PendingSheetCode = null; PendingSheetCode = null;
@@ -509,22 +504,22 @@ internal sealed class MainViewModel : ViewModelBase
} }
} }
OnNewFile(); // 아무것도 열지 않은 상태로 둔다. 전에는 빈 새 문서를 만들어 두었는데,
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림) // 그건 <b>저장할 수 없는 문서</b>다 — 서식 코드가 없으니 DB 저장이 막히고,
// 사용자는 한참 그려 넣은 뒤에야 그 사실을 알게 된다.
// 서식은 반드시 목록에서 골라 연다(그래야 코드·권한·기존 디자인이 함께 온다).
SelectedLeftTabIndex = 0;
StatusText = "서식 목록에서 서식을 골라 여세요.";
} }
private void OnNewFile() /// <summary>
{ /// 빈 문서를 붙인다 — <b>진단 전용</b>이다(--dialog-shots 가 화면을 찍으려면 문서가 필요하다).
try ///
{ /// UI 에는 '새 서식'이 없다. 코드 없는 문서는 DB 에 저장할 수 없고,
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew())); /// 그린 뒤에 그것을 알게 되는 것이 이 앱에서 가장 비싼 실수였다.
StatusText = "새 서식 (720×856)"; /// </summary>
} internal void AttachBlankForDiagnostics()
catch (Exception ex) => AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
{
DialogService.ShowError("새 서식 만들기", ex);
}
}
private void OnOpenFile() private void OnOpenFile()
{ {
@@ -983,9 +978,12 @@ internal sealed class MainViewModel : ViewModelBase
{ {
return; return;
} }
// 미리보기를 안 거친 인쇄도 세션 환자를 따른다 — 해석기를 안 넘기면
// 태그 이름이 그대로 인쇄된다(미리보기 인쇄에서 실제로 그랬다).
var (tags, fields) = Services.PatientSession.ResolversFor(CurrentDesigner.Document);
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0 Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
? CurrentDesigner.Document.Title ? CurrentDesigner.Document.Title
: CurrentDesigner.Document.FormId); : CurrentDesigner.Document.FormId, tags, fields);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -55,6 +55,22 @@ public sealed class SpacingMarkInfo
public double TextY { get; init; } 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> /// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
public sealed class TabBadgeInfo public sealed class TabBadgeInfo
{ {
@@ -100,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
private double selW; private double selW;
private double selH; private double selH;
private bool showHandles; private bool showHandles;
private string dragBadgeText = string.Empty;
private bool showDragBadge;
private double dragBadgeX;
private double dragBadgeY;
private bool hasMarquee; private bool hasMarquee;
private double marX; private double marX;
private double marY; private double marY;
@@ -172,6 +192,54 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary> /// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new(); 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 #endregion
#region Methods #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" <Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="데이터 필드 배선" Width="560" Height="520" Title="값 가져오기" Width="620" Height="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="14"> <!--
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10" 이 창은 "조회 결과의 한 칸을 이 컨트롤에 채우는" 설정이다.
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
<!-- 아래쪽 버튼 --> <b>왜 다시 썼나.</b> 전에는 네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어서
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> 넷 다 채워야 하는 것처럼 보였다. 정작 <b>필수는 둘</b>(데이터소스·컬럼)이고
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/> 그 둘이 화면에서 가장 눈에 안 띄는 자리에 있었다.
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0" 게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 라,
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/> 화면이 "무엇을 먼저 하나"의 답을 '지우기' 로 주고 있었다.
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<!-- 조립 결과 미리보기 + 검증 메시지 --> 실사용 근거: 조건(필터)을 쓴 배선이 <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" <Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1"> Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
<StackPanel> <StackPanel>
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/> <TextBlock x:Name="PlainText" TextWrapping="Wrap" FontSize="12.5"/>
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0" <TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/> 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> </StackPanel>
</Border> </Border>
@@ -34,39 +63,67 @@
<ColumnDefinition Width="*"/> <ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions> </Grid.ColumnDefinitions>
<!-- 데이터소스 --> <!-- 데이터소스 -->
<DockPanel Grid.Column="0"> <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" <TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed" Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/> 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> </DockPanel>
<!-- 나머지 조각 --> <!-- ② 컬럼 + ③ 접힌 행 고르기 -->
<StackPanel Grid.Column="2"> <DockPanel Grid.Column="2">
<TextBlock Text="형태" Margin="0,0,0,4"/> <TextBlock DockPanel.Dock="Top" Text="② 가져올 컬럼" FontWeight="SemiBold" Margin="0,0,0,4"/>
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged" <TextBox x:Name="FieldBox" DockPanel.Dock="Top" TextChanged="OnFormChanged"/>
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다."> <TextBlock x:Name="FieldHint" DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
<ComboBoxItem Content="Select — 필터로 행을 고름"/> Foreground="{DynamicResource B.Muted}"/>
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
</ComboBox>
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/> <!--
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged" ③ 은 접어 둔다. 조건을 쓴 실사용 배선이 0건이고 행 번호는 98%가 0 이라,
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/> 첫 방문자가 평생 안 쓸 개념을 먼저 보게 할 이유가 없다.
기본값이 아닐 때만 머리에 칩을 띄워 접혀 있어도 숨지 않게 한다.
-->
<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부터 셉니다"/> 조건은 저장된 값이 조건 형태일 때만 보인다. 새로 만들 때는 아예 안 보인다 —
실사용에서 조건을 쓴 배선이 0건이고, 이 문법은 SQL WHERE 가 아니라
<TextBlock Text="필드" Margin="0,10,0,4"/> 이미 받아 온 표를 거르는 식이라 NVL·TO_CHAR·|| 가 통하지 않는다.
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged" -->
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/> <StackPanel x:Name="FilterArea" Visibility="Collapsed">
<TextBlock Text="행 조건" Margin="0,12,0,4"/>
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0" <TextBox x:Name="FilterBox" TextChanged="OnFormChanged"/>
Foreground="{DynamicResource B.Muted}" <TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/> Foreground="{DynamicResource B.Muted}"
</StackPanel> 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> </Grid>
</DockPanel> </DockPanel>
</Window> </Window>
@@ -6,30 +6,74 @@ using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
/// <summary> /// <summary>
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다. /// 조회 결과의 한 칸을 컨트롤에 채우는 설정 — 문법 골격을 코드가 조립하고 저장 전에 검사한다.
/// ///
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해 /// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData). /// 한 조각만 고치려 해도 전부 다시 골라야 했다(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> /// </summary>
public partial class DataTableFieldDialogView : Window public partial class DataTableFieldDialogView : Window
{ {
#region Member Fields #region Member Fields
private readonly IReadOnlyList<string> tables; 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; private bool loading = true;
/// <summary>연 값의 형태 — 저장할 때 그대로 되돌려 쓴다(원문을 우리가 바꾸지 않는다)</summary>
private DataTableFieldForm form = DataTableFieldForm.Rows;
#endregion #endregion
#region Properties #region Properties
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary> /// <summary>적용 결과 — 취소면 null, '가져오지 않음'이면 빈 문자열</summary>
public string? Result { get; private set; } public string? Result { get; private set; }
#endregion #endregion
#region Constructors #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(); InitializeComponent();
tables = dataTables; tables = dataTables;
this.columnsOf = columnsOf;
if (ownerLabel is { Length: > 0 })
{
// 모달이라 인스펙터가 가려진다 — 여러 칸을 연달아 채울 때 어느 칸 차례인지 알 수 없었다
Title = $"값 가져오기 — {ownerLabel}";
}
foreach (var name in tables) foreach (var name in tables)
{ {
@@ -40,16 +84,21 @@ public partial class DataTableFieldDialogView : Window
var spec = DataTableFieldSpec.Parse(current); var spec = DataTableFieldSpec.Parse(current);
if (spec is not null) 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)); TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
FilterBox.Text = spec.Filter; FilterBox.Text = spec.Filter;
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture); RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
FieldBox.Text = spec.Field; FieldBox.Text = spec.Field;
// 기본값이 아닌 것이 접혀 있으면 숨는다 — 그때만 펼친 채로 연다
if (spec.RowIndex != 0 || (form == DataTableFieldForm.Select && spec.Filter.Length > 0))
{
RowExpander.IsExpanded = true;
}
} }
else else
{ {
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다 // 새 배선 — 조건 없이 첫 행. 첫 화면에 평생 안 쓸 개념을 두지 않는다.
FormBox.SelectedIndex = 0; form = DataTableFieldForm.Rows;
RowBox.Text = "0"; RowBox.Text = "0";
if (tables.Count == 1) if (tables.Count == 1)
{ {
@@ -59,47 +108,134 @@ public partial class DataTableFieldDialogView : Window
loading = false; loading = false;
Refresh(); Refresh();
// 첫 포커스 — 데이터소스가 이미 정해졌으면 다음 할 일(컬럼)로 보낸다.
// 이 호출이 없으면 선언 순서상 첫 Tab 정지점이 파괴적 버튼이었다.
Loaded += (_, _) =>
{
if (TableList.SelectedItem is null && TableList.Items.Count > 0)
{
TableList.Focus();
}
else
{
FieldBox.Focus();
}
};
} }
#endregion #endregion
#region Methods #region Methods
/// <summary> /// <summary>
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다 /// 현재 입력을 명세로 옮긴다.
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135). ///
/// <b>행 번호를 조용히 0 으로 바꾸지 않는다.</b> 전에는 파싱 실패가 그대로 0 이 됐는데,
/// 그러면 전각 '1' 을 넣은 배선이 아무 말 없이 첫 행을 가리킨다(실사용에 그런 값이 실재한다).
/// 잘못된 입력은 <see cref="Refresh"/> 가 이유를 말하고 확인을 막는다.
/// </summary> /// </summary>
private DataTableFieldSpec CurrentSpec() private DataTableFieldSpec CurrentSpec() => new()
{ {
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed) Form = form,
? parsed TableName = TableList.SelectedItem as string ?? string.Empty,
: 0; Filter = form == DataTableFieldForm.Select ? FilterBox.Text : string.Empty,
return new DataTableFieldSpec 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, return 0; // 빈 값은 첫 행이다(레거시도 빈 값을 0 으로 본다)
TableName = TableList.SelectedItem as string ?? string.Empty, }
Filter = FilterBox.Text, return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
RowIndex = row,
Field = FieldBox.Text.Trim(),
};
} }
private void Refresh() private void Refresh()
{ {
// 조건 칸은 저장된 값이 조건 형태일 때만 보인다
FilterArea.Visibility = form == DataTableFieldForm.Select ? Visibility.Visible : Visibility.Collapsed;
var spec = CurrentSpec(); var spec = CurrentSpec();
var rowsForm = spec.Form == DataTableFieldForm.Rows; RawText.Text = spec.Compose();
UpdateRowChip(spec);
UpdateFieldHint(spec.TableName);
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된 // 전각 숫자처럼 읽을 수 없는 행 번호는 <b>먼저</b> 말한다 — 조용히 0 으로 바꾸지 않는
FilterBox.IsEnabled = !rowsForm; var problem = ParsedRow() is null
FilterLabel.Opacity = rowsForm ? 0.45 : 1; ? "몇 번째 행: 반각 숫자만 됩니다"
FilterBox.Opacity = rowsForm ? 0.45 : 1; // 고를 데이터소스가 아예 없으면 '고르세요' 는 따를 수 없는 지시다 — 다음 행동을 말한다
: tables.Count == 0
? "이 서식에 데이터소스가 없습니다 — 팔레트에서 먼저 놓고 쿼리를 지정하세요"
: spec.Validate(tables);
PreviewText.Text = spec.Compose(); PlainText.Text = problem is null ? PlainSentence(spec) : "아직 가져올 값이 정해지지 않았습니다.";
var problem = spec.Validate(tables);
ProblemText.Text = problem ?? string.Empty; ProblemText.Text = problem ?? string.Empty;
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible; ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다 // 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
OkButton.IsEnabled = problem is null; 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) private void OnFormChanged(object sender, RoutedEventArgs e)
{ {
if (!loading) 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) private void OnApply(object sender, RoutedEventArgs e)
{ {
if (ParsedRow() is null)
{
return;
}
var spec = CurrentSpec(); var spec = CurrentSpec();
if (spec.Validate(tables) is not null) if (spec.Validate(tables) is not null)
{ {
@@ -26,6 +26,20 @@
--> -->
<UserControl.ContextMenu> <UserControl.ContextMenu>
<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" <MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
Command="{Binding AlignCommand}" CommandParameter="Left"/> Command="{Binding AlignCommand}" CommandParameter="Left"/>
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D" <MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
@@ -61,6 +75,13 @@
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/> <MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/> <MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
<Separator/> <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="Ctrl+D" Command="{Binding DuplicateCommand}"/>
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/> <MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
</ContextMenu> </ContextMenu>
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420" Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8" <TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/> Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
@@ -23,7 +23,10 @@
<DockPanel Margin="0,10,0,0"> <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="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" 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}"/> <TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
</DockPanel> </DockPanel>
</StackPanel> </StackPanel>
+101 -8
View File
@@ -11,6 +11,7 @@
<UserControl.Resources> <UserControl.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:ChoiceLabelConverter x:Key="ChoiceLabel"/>
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/> <BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
<!-- <!--
@@ -174,7 +175,8 @@
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다). (자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
--> -->
<Style x:Key="RowLabel" TargetType="TextBlock"> <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="VerticalAlignment" Value="Center"/>
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/> <Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
<Setter Property="FontSize" Value="12"/> <Setter Property="FontSize" Value="12"/>
@@ -299,6 +301,18 @@
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/> <Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/>
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/> <Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
</Trigger> </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.Triggers>
</ControlTemplate> </ControlTemplate>
</Setter.Value> </Setter.Value>
@@ -378,6 +392,38 @@
</Button> </Button>
</DataTemplate> </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}"> <DataTemplate DataType="{x:Type ins:TextRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
@@ -425,6 +471,15 @@
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}"> <DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/> <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 ItemsSource="{Binding Cells}">
<ItemsControl.ItemsPanel> <ItemsControl.ItemsPanel>
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate> <ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
@@ -533,7 +588,28 @@
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
<Grid> <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"/> <TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
</Grid> </Grid>
</DockPanel> </DockPanel>
@@ -547,6 +623,16 @@
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0" <Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택" Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/> 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> <Grid>
<TextBox x:Name="TagInput" Style="{StaticResource RowInput}" <TextBox x:Name="TagInput" Style="{StaticResource RowInput}"
bh:InspectorFieldBehavior.CommitOnEnter="True" bh:InspectorFieldBehavior.CommitOnEnter="True"
@@ -609,8 +695,15 @@
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}"> <DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
<DockPanel Margin="0,2"> <DockPanel Margin="0,2">
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/> <TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
<!--
이 자리는 값이 아니라 <b>설명</b>이다 — 자르면 결론이 사라진다.
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
지시에 해당하는 뒷부분이 잘려 나가 무엇을 하라는 건지 알 수 없었다.
접어서 다 보여 주고, 그래도 좁으면 툴팁이 받는다.
-->
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}" <TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/> VerticalAlignment="Center" TextWrapping="Wrap"
ToolTip="{Binding ValueText}"/>
</DockPanel> </DockPanel>
</DataTemplate> </DataTemplate>
@@ -821,11 +914,11 @@
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" <ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center"
IsTabStop="False" Focusable="False" IsTabStop="False" Focusable="False"
Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/> Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/>
<Button DockPanel.Dock="Right" Width="24" Height="24" Padding="0" <!--
Style="{StaticResource Subtle}" ToolTip="이 컨트롤에 대한 명령" ⋮ 버튼을 지웠다. Command·Click·ContextMenu 가 <b>전부 없어</b> 눌러도 아무 일이 없었는데,
AutomationProperties.Name="더보기" 툴팁은 '이 컨트롤에 대한 명령' 이라 '단일 선택일 때만 되는 뭔가가 있다'고 오해시켰다.
Visibility="{Binding IsSingleSelection, Converter={StaticResource BoolToVisibility}}" 없는 버튼보다 죽은 버튼이 나쁘다. 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 다 있다.
Content="{Binding Source=ellipsis-vertical, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/> -->
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5" <TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center" Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/> TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
+42 -8
View File
@@ -283,13 +283,41 @@
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}" <ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}"
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김" IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/> 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 --> <!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 -->
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/> <Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 --> <!--
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16" 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다.
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기" <b>없을 때도 16px 칸은 남긴다.</b> 전에는 셰브론이 Collapsed 로 사라져
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}" 폭 16 이 통째로 없어졌는데, 컨테이너 자식 들여쓰기도 정확히 16 이라
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/> <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" <ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False"
Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/> Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/>
<!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 --> <!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 -->
@@ -321,10 +349,16 @@
</Style> </Style>
</TextBlock.Style> </TextBlock.Style>
</TextBlock> </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" TextTrimming="CharacterEllipsis"
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다" ToolTip="{Binding LayerSubText}">
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}">
<TextBlock.Style> <TextBlock.Style>
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}"> <Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/> <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.X when ctrl: designer.CutSelection(); break;
case System.Windows.Input.Key.V when ctrl: designer.Paste(); 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; 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.Z when ctrl: designer.Undo.Undo(); break;
case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break; case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break;
case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break; case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break;
@@ -4,18 +4,18 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="타입으로 거르기" Width="380" Height="520" Title="타입으로 거르기" Width="380" Height="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources> <Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
<ctl:TypeToIconConverter x:Key="TypeToIcon"/> <ctl:TypeToIconConverter x:Key="TypeToIcon"/>
</Window.Resources> </Window.Resources>
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8" <TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/> Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/> <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"/> <Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
+49 -5
View File
@@ -25,11 +25,29 @@
처리하며, 여기 올리면 인스펙터·검색 상자 등 TextBox 의 표준 편집 키와 충돌한다. 처리하며, 여기 올리면 인스펙터·검색 상자 등 TextBox 의 표준 편집 키와 충돌한다.
--> -->
<Window.InputBindings> <Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewFileCommand}"/>
<!-- Ctrl+O 는 코드비하인드에서 서식 목록으로 보낸다(파일 대화상자가 아니다) --> <!-- Ctrl+O 는 코드비하인드에서 서식 목록으로 보낸다(파일 대화상자가 아니다) -->
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/> <KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/> <KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/> <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 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에 <!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. --> 하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 --> <!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
@@ -164,7 +182,6 @@
edit-smoke 직렬화 왕복)이 그 경로를 쓴다. UI 진입점만 없앤 것이다. edit-smoke 직렬화 왕복)이 그 경로를 쓴다. UI 진입점만 없앤 것이다.
--> -->
<MenuItem Header="파일(_F)"> <MenuItem Header="파일(_F)">
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
<MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/> <MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/>
<Separator/> <Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S" <MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S"
@@ -296,6 +313,14 @@
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="다크/라이트 전환" <Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="다크/라이트 전환"
Click="OnThemeToggleClick" Click="OnThemeToggleClick"
Content="{Binding Source=sun, Converter={StaticResource IconName}, ConverterParameter=16}"/> 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에 저장" <Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="DB에 저장"
Command="{Binding SaveToDbCommand}" Command="{Binding SaveToDbCommand}"
Content="{Binding Source=save, Converter={StaticResource IconName}, ConverterParameter=16}"/> Content="{Binding Source=save, Converter={StaticResource IconName}, ConverterParameter=16}"/>
@@ -593,6 +618,16 @@
</ContentControl> </ContentControl>
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}" <TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/> 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" <Button Content="✕" FontSize="11" Margin="7,0,0,0"
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기" Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
Command="{Binding DataContext.CloseDocumentCommand, Command="{Binding DataContext.CloseDocumentCommand,
@@ -752,9 +787,8 @@
HorizontalAlignment="Center" Margin="0,16,0,6"/> HorizontalAlignment="Center" Margin="0,16,0,6"/>
<TextBlock Foreground="{DynamicResource B.Muted}" FontSize="12.5" TextAlignment="Center" <TextBlock Foreground="{DynamicResource B.Muted}" FontSize="12.5" TextAlignment="Center"
TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0,0,0,20" TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0,0,0,20"
Text="새 서식을 만들거나, 왼쪽 서식 목록에서 기존 서식을 두 번 눌러 엽니다."/> Text="왼쪽 서식 목록에서 서식을 두 번 눌러 엽니다. (F4 또는 Ctrl+O)"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="새 서식" Command="{Binding NewFileCommand}" Padding="16,7" Margin="0,0,8,0"/>
<Button Content="서식 목록에서 열기" Click="OnOpenFromListClick" Padding="16,7" <Button Content="서식 목록에서 열기" Click="OnOpenFromListClick" Padding="16,7"
IsEnabled="{Binding CanUseDb}"/> IsEnabled="{Binding CanUseDb}"/>
</StackPanel> </StackPanel>
@@ -771,7 +805,17 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1"> BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/> <TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
</Border> </Border>
<v:InspectorView x:Name="Inspector" 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> </DockPanel>
</Border> </Border>
</Grid> </Grid>
+128
View File
@@ -14,6 +14,12 @@ public partial class MainView : Window
public MainView() public MainView()
{ {
InitializeComponent(); InitializeComponent();
// 환자는 앱 전체가 하나를 공유한다 — 어느 화면에서 바뀌어도 이 아이콘이 따라와야 한다.
// 창이 살아 있는 동안만 듣는다(정적 이벤트라 떼지 않으면 창이 회수되지 않는다).
RefreshPatientIcon();
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => PatientSession.Changed -= OnPatientSessionChanged;
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록 // StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount; AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount; ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
@@ -73,6 +79,73 @@ public partial class MainView : Window
ThemeMenuItem.IsChecked = !ThemeManager.IsLight; 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> /// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e) private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
{ {
@@ -163,10 +236,24 @@ public partial class MainView : Window
{ {
if (e.Key == System.Windows.Input.Key.F4) if (e.Key == System.Windows.Input.Key.F4)
{ {
// 인스펙터가 접혀 있으면 먼저 편다 — 안 그러면 보이지도 않는 칸으로 포커스가 들어간다
if (panelsCollapsed)
{
TogglePanels();
}
Inspector.FocusFirstRow(); Inspector.FocusFirstRow();
e.Handled = true; e.Handled = true;
return; 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 파일 대화상자로 갔다. // Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다.
// 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다. // 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다.
if (e.Key == System.Windows.Input.Key.O if (e.Key == System.Windows.Input.Key.O
@@ -177,6 +264,47 @@ public partial class MainView : Window
} }
} }
/// <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> /// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary>
private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList(); private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList();
@@ -3,8 +3,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="마스크 편집" Width="620" Height="620" Title="마스크 편집" Width="620" Height="620"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<!-- <!--
백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다. 백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다.
아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다. 아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다.
@@ -14,7 +14,7 @@
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<Button Content="마스크 없음" Padding="12,4" Click="OnClear"/> <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"/> <Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
@@ -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
}
+70 -11
View File
@@ -1,34 +1,93 @@
<Window x:Class="SheetMe.Designer.Views.PreviewWindow" <Window x:Class="SheetMe.Designer.Views.PreviewWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="미리보기" Width="900" Height="1000" Title="미리보기" Width="900" Height="1000"
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}" WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/>
</Window.Resources>
<DockPanel> <DockPanel>
<ToolBarTray DockPanel.Dock="Top"> <ToolBarTray DockPanel.Dock="Top">
<ToolBar> <ToolBar>
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/> <!--
<Button Content="새로 고침" Padding="10,3" Click="OnRefresh" 아이콘 버튼으로 둔다. 전에는 여덟 개가 전부 글자라 툴바가 글자 벽이었고,
ToolTip="구조 변경은 자동으로 따라오고, 창을 누르면 다시 그립니다"/> 무엇이 명령이고 무엇이 상태인지 구분되지 않았다.
글자로 남기는 것은 <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/> <Separator/>
<!-- <!--
레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를 레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를
그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다. 그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다.
기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다. 기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다.
토글은 눌린 상태가 곧 '인쇄 기준'이라는 뜻이다(툴팁이 양쪽을 설명한다).
--> -->
<CheckBox x:Name="PrintViewToggle" Content="인쇄될 것만" IsChecked="True" <ToggleButton x:Name="PrintViewToggle" IsChecked="True"
VerticalAlignment="Center" Margin="4,0" Style="{StaticResource MiniToggle}" Width="34" Height="28" Padding="0"
Checked="OnViewToggled" Unchecked="OnViewToggled" Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="끄면 화면에 보이는 대로인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/> ToolTip="인쇄될 것만 보기 — 끄면 화면에 보이는 대로(인쇄 출력을 끈 컨트롤도 보입니다)"
Content="{Binding Source=eye, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<Separator/> <Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/> <!--
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
선택·해제를 <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"/> <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> </ToolBar>
</ToolBarTray> </ToolBarTray>
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto"> <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> <StackPanel.LayoutTransform>
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/> <ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
</StackPanel.LayoutTransform> </StackPanel.LayoutTransform>
@@ -2,6 +2,7 @@ using System.Windows;
using System.Windows.Controls; using System.Windows.Controls;
using SheetMe.Designer.Services; using SheetMe.Designer.Services;
using SheetMe.Designer.ViewModels; using SheetMe.Designer.ViewModels;
using SheetMe.Designer.ViewModels.Controls;
namespace SheetMe.Designer.Views; namespace SheetMe.Designer.Views;
@@ -28,10 +29,32 @@ public partial class PreviewWindow : Window
private double zoom = 1.0; private double zoom = 1.0;
/// <summary> /// <summary>
/// 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다. /// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다. /// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
/// </summary> /// </summary>
private readonly SessionTagResolver tags = new(); 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 #endregion
#region Constructors #region Constructors
@@ -42,18 +65,25 @@ public partial class PreviewWindow : Window
internal PreviewWindow(DesignerViewModel designer) internal PreviewWindow(DesignerViewModel designer)
{ {
this.designer = designer; this.designer = designer;
tags = session;
InitializeComponent(); InitializeComponent();
Activated += (_, _) => BuildPages(); Activated += (_, _) => BuildPages();
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => Closed += (_, _) =>
{ {
Unsubscribe(); Unsubscribe();
PatientSession.Changed -= OnPatientSessionChanged;
if (ReferenceEquals(open, this)) if (ReferenceEquals(open, this))
{ {
open = null; open = null;
} }
}; };
Subscribe(); Subscribe();
BuildPages(); // 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
UsePatient(PatientSession.Current, PatientSession.Label);
} }
#endregion #endregion
@@ -89,7 +119,9 @@ public partial class PreviewWindow : Window
Unsubscribe(); Unsubscribe();
designer = next; designer = next;
Subscribe(); Subscribe();
BuildPages(); // 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
UsePatient(patient, PatientText.Text);
} }
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary> /// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
@@ -143,12 +175,15 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray, BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1), BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20), Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags), Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields, checks),
Width = page.WidthDip, Width = page.WidthDip,
Height = page.HeightDip, Height = page.HeightDip,
// 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
Tag = page,
}; };
PagesHost.Children.Add(frame); PagesHost.Children.Add(frame);
} }
ResetChecksButton.IsEnabled = checks.HasAny;
Title = designer.Document.Title.Length > 0 Title = designer.Document.Title.Length > 0
? $"미리보기 — {designer.Document.Title}" ? $"미리보기 — {designer.Document.Title}"
: "미리보기"; : "미리보기";
@@ -156,6 +191,220 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); 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> /// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages(); private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
@@ -163,9 +412,12 @@ public partial class PreviewWindow : Window
{ {
try try
{ {
// 이 창이 종이에 그리는 것과 <b>같은 해석기</b>로 인쇄한다.
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0 PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title ? designer.Document.Title
: designer.Document.FormId); : designer.Document.FormId, tags, fields, checks);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -4,7 +4,7 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520" Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<Window.Resources> <Window.Resources>
<!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 --> <!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 -->
<ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/> <ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/>
@@ -241,6 +241,14 @@
<TextBlock Text="검증 실행" VerticalAlignment="Center"/> <TextBlock Text="검증 실행" VerticalAlignment="Center"/>
</StackPanel> </StackPanel>
</Button> </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" <Button Content="확인" Padding="30,8" Margin="10,0,0,0"
Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/> Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="26,8" Margin="10,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="26,8" Margin="10,0,0,0" IsCancel="True"/>
@@ -259,7 +267,11 @@
<!-- ══ SQL 편집기 카드 ══ --> <!-- ══ SQL 편집기 카드 ══ -->
<Border Grid.Column="0" Style="{StaticResource Card}"> <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}" <TextBlock DockPanel.Dock="Top" Text="SQL 편집기" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/> Margin="0,0,0,10"/>
@@ -305,13 +317,39 @@
</DockPanel> </DockPanel>
</Border> </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"> <DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="닫기" <Button DockPanel.Dock="Right" Content="닫기"
Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/> 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" <TextBlock x:Name="TrialSummary" FontSize="12.5" TextWrapping="Wrap"
VerticalAlignment="Center"/> VerticalAlignment="Center"/>
</DockPanel> </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}"> <Border BorderThickness="1" BorderBrush="{DynamicResource B.Line2}">
<DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200" <DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200"
@@ -326,8 +364,13 @@
본문 사이 구분선까지 함께 휘어 보인다. --> 본문 사이 구분선까지 함께 휘어 보인다. -->
<!-- 본문 면은 B.Surface 다. B.Input 을 쓰면 다크에서 홈통(B.Chip)과 같은 #262626 이라 <!-- 본문 면은 B.Surface 다. B.Input 을 쓰면 다크에서 홈통(B.Chip)과 같은 #262626 이라
홈통이 통째로 사라진다(양 토큰이 같은 값이다). --> 홈통이 통째로 사라진다(양 토큰이 같은 값이다). -->
<!-- ClearTypeHint 를 여기에 건다. 카드 DockPanel 에만 걸어 봤더니 SQL 글자는
계속 뭉개졌다 — 본문은 이 아래 SqlHighlightLayer 가 그리고, 그 사이에
또 다른 중간 서피스가 있다(진단 cleartype 표본 I·J — ScrollViewer 도 경계다).
이 Border 는 불투명 B.Surface 를 갖고 Effect 경계 안쪽이라 조건이 맞는다. -->
<Border BorderThickness="1" CornerRadius="0" <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>
<Grid.ColumnDefinitions> <Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/> <ColumnDefinition Width="Auto"/>
@@ -393,14 +436,21 @@
<!-- ══ 치환 변수 카드 ══ --> <!-- ══ 치환 변수 카드 ══ -->
<Border x:Name="VariablePane" Grid.Column="2" Style="{StaticResource Card}"> <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}" <TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/> Margin="0,0,0,10"/>
<!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 --> <!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 -->
<Grid DockPanel.Dock="Top" Margin="0,0,0,8"> <Grid DockPanel.Dock="Top" Margin="0,0,0,8">
<!-- KeyDown 필수 — 확인 버튼이 IsDefault 라 이 칸의 Enter 가
'저장하고 닫기'로 새어 나간다(변수를 못 넣은 채 편집이 끝난다) -->
<TextBox x:Name="VariableSearch" Padding="28,4,8,4" <TextBox x:Name="VariableSearch" Padding="28,4,8,4"
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged" VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
KeyDown="OnVariableSearchKeyDown"
AutomationProperties.Name="치환 변수 검색" AutomationProperties.Name="치환 변수 검색"
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/> ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
<TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False" <TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False"
@@ -439,6 +489,19 @@
HorizontalContentAlignment="Stretch" HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="치환 변수 목록"> 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> <ListBox.ItemTemplate>
<DataTemplate> <DataTemplate>
<DockPanel Margin="0,3" ToolTip="{Binding Token}"> <DockPanel Margin="0,3" ToolTip="{Binding Token}">
@@ -25,6 +25,28 @@ public partial class QueryEditorWindow : Window
#region Member Fields #region Member Fields
private readonly bool isSql; private readonly bool isSql;
/// <summary>
/// 아무 변수도 모르는 원천 — 환자를 안 골랐을 때 쓴다.
/// 전부 Unknown 이 되고, 자리표시(NULL)가 채워져 구문 검사는 그대로 돌아간다.
/// </summary>
private sealed class UnknownVariables : SheetMe.Core.Catalog.IQueryVariableSource
{
public static readonly UnknownVariables Instance = new();
public string? Scalar(string className, string property) => null;
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
}
/// <summary>마지막으로 돌린 SQL — 진단이 "무엇을 돌렸는가"를 확인할 때 쓴다</summary>
private string lastTrialSql = string.Empty;
/// <summary>검증을 돌린 시점의 SQL 원문 — 그 뒤 고쳤는지 판정한다</summary>
private string trialBaseline = string.Empty;
/// <summary>진단 전용 — 검증 실행이 실제로 만든 SQL</summary>
internal string LastTrialSql => lastTrialSql;
/// <summary>스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다</summary> /// <summary>스키마·검증 실행 원천 — DB 가 없으면 null 이고 그 기능만 꺼진다</summary>
private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench; private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench;
@@ -119,6 +141,10 @@ public partial class QueryEditorWindow : Window
TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed); TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed);
RebuildBucketChips(); RebuildBucketChips();
ApplyVariableFilter(string.Empty); ApplyVariableFilter(string.Empty);
RefreshPatientButton();
// 제목줄이나 미리보기에서 환자를 바꿔도 이 버튼이 따라와야 한다
Services.PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => Services.PatientSession.Changed -= OnPatientSessionChanged;
var connection = Services.ConfigService.Current.ConnectionString; var connection = Services.ConfigService.Current.ConnectionString;
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection); workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
if (!workbench.CanUseDb) if (!workbench.CanUseDb)
@@ -171,6 +197,43 @@ public partial class QueryEditorWindow : Window
/// <summary>진단(--query-popup)에서 검증 실행을 부르는 통로 — 결과 표가 실제로 차는지 본다</summary> /// <summary>진단(--query-popup)에서 검증 실행을 부르는 통로 — 결과 표가 실제로 차는지 본다</summary>
internal void RunTrialForSmoke() => OnTrial(this, new RoutedEventArgs()); internal void RunTrialForSmoke() => OnTrial(this, new RoutedEventArgs());
/// <summary>
/// 환자를 고른다 — 고르면 검증 실행이 <b>실제 값</b>으로 돈다.
///
/// 권한 문·감사 기록·문맥 읽기는 전부 <see cref="PatientPickerDialogView.Pick"/> 안에 있다.
/// 세션 하나를 공유하므로 미리보기와 같은 환자다 — 두 화면이 다른 환자를 보면 구분할 수 없다.
/// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e)
{
if (Services.PatientSession.HasPatient)
{
// 이미 고른 환자가 있으면 이 버튼은 '해제'다. 바꾸려면 해제하고 다시 고른다 —
// 버튼 하나에 '고르기/바꾸기/해제'를 다 넣으면 무엇이 일어날지 예측할 수 없다.
Services.PatientSession.Set(null, string.Empty);
return;
}
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
{
Services.PatientSession.Set(picked, label);
}
// 버튼 글자는 Changed 를 듣고 갱신한다 — 여기서 직접 부르면
// 제목줄에서 바꿨을 때만 어긋나는 경로가 하나 생긴다.
}
private void OnPatientSessionChanged(object? sender, EventArgs e) => RefreshPatientButton();
/// <summary>버튼이 지금 무엇을 하는지 글자로 말한다 — 상태를 색으로만 알리면 못 읽는다</summary>
private void RefreshPatientButton()
{
if (PatientButton is null)
{
return;
}
PatientButton.Content = Services.PatientSession.HasPatient
? $"환자 해제 — {Services.PatientSession.Label}"
: "환자 선택...";
}
/// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary> /// <summary>커서 위치를 보고 목록을 띄우거나 닫는다</summary>
private void UpdateCompletion() private void UpdateCompletion()
{ {
@@ -465,10 +528,18 @@ public partial class QueryEditorWindow : Window
#region Methods - Trial #region Methods - Trial
/// <summary> /// <summary>
/// 검증 실행 — 치환 변수를 자리표시로 바꾼 뒤 실제로 돌려 본다. /// 검증 실행 — 치환을 <b>실제로</b> 한 뒤 돌려 본다.
/// ///
/// 디자인 시점에는 환자·서식 문맥이 없어 진짜 값을 넣을 수 없다. /// <b>전에는 변수를 빈 문자열로 지웠다.</b> 런타임이 값 없을 때 그렇게 한다는 이유였지만
/// 목적은 데이터 확인이 아니라 <b>테이블·컬럼 오타와 구문 오류</b>를 배포 전에 잡는 것이다. /// (clsMDataTable.vb:67-71), 그 결과 <c>WHERE X = </c> 가 되어 ORA-00936 이 났다 —
/// 즉 <b>치환 변수를 값 자리에 쓴 쿼리는 검증 실행을 통과할 수가 없었다.</b>
/// 정작 이 기능이 존재하는 이유가 그런 쿼리들이다.
///
/// 이제 두 갈래다:
/// · 환자를 골랐으면(<see cref="PatientSession"/>) <b>실제 값</b>으로 치환한다 —
/// 구문뿐 아니라 값까지 확인된다. 미리보기와 같은 환자라 결과도 같다.
/// · 안 골랐으면 빈 자리를 <c>NULL</c> 로 채운다. 따옴표 안이든 밖이든 문법이 성립하고
/// 0행이 나오므로 <b>테이블·컬럼 오타와 구문 오류</b>는 그대로 걸린다.
/// </summary> /// </summary>
private void OnTrial(object sender, RoutedEventArgs e) private void OnTrial(object sender, RoutedEventArgs e)
{ {
@@ -476,12 +547,16 @@ public partial class QueryEditorWindow : Window
{ {
return; return;
} }
var probe = SqlBox.Text; var variables = Services.PatientSession.Variables();
foreach (var token in SqlTokenizer.VariablesIn(probe).Distinct(StringComparer.Ordinal)) var converted = SheetMe.Core.Catalog.QuerySubstitution.Apply(
{ SqlBox.Text, variables ?? UnknownVariables.Instance,
// 런타임도 값이 없으면 빈 문자열로 치환한다(clsMDataTable.vb:67-71) — 같은 모양으로 맞춘다 // 환자가 있어도 자리표시는 준다 — 아직 안 옮긴 변수(Age·Sex)가 섞여 있으면
probe = probe.Replace(token, string.Empty, StringComparison.Ordinal); // 그것만 빈 값이 되어 같은 ORA-00936 이 난다.
} emptyPlaceholder: "NULL");
var probe = converted.Sql;
lastTrialSql = probe;
// 치환 <b>전</b> 원문을 기준으로 삼는다 — 사용자가 고치는 것은 이쪽이다
trialBaseline = SqlBox.Text;
Mouse.OverrideCursor = Cursors.Wait; Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result; SheetMe.Data.Stores.QueryTrialResult result;
@@ -495,26 +570,98 @@ public partial class QueryEditorWindow : Window
} }
TrialPane.Visibility = Visibility.Visible; TrialPane.Visibility = Visibility.Visible;
// 무엇으로 돌렸는지는 말해야 하지만 <b>환자를 두 번 적지 않는다</b> —
// 아래 버튼이 이미 "환자 해제 — 강한(0001381872) · 외래 …" 를 그대로 보여 준다.
// 같은 정보를 두 군데 적으면 요약이 두 줄로 늘어 결과 표를 밀어내고,
// 화면에 환자 이름이 필요 이상으로 여러 번 남는다.
// 환자가 있을 때 필요한 말은 이미 버튼이 하고 있으므로 여기서는 침묵한다.
// 치환 대상이 <b>하나도 없는</b> 쿼리에 '변수 NULL' 을 적으면 거짓이다 —
// 치환할 것이 없으니 NULL 로 바뀐 것도 없다. 그런데 그 꼬리표가 늘 붙어 있어서
// 정작 진짜 경고가 붙은 날에도 눈에 안 들어왔다. 늘 뜨는 경고는 경고가 아니다.
var hasSubstitution = converted.Hits.Count > 0;
var basis = variables is null && hasSubstitution ? "변수 NULL — 구문만 검사" : string.Empty;
var notes = new List<string>();
// 환자를 안 골랐으면 <b>모든</b> 변수가 Unknown 이다 — 그것을 "아직 옮기지 않았다"고 적으면
// 거짓이 된다(ChtNum 은 옮겨져 있다). 그 사유는 위 basis 줄이 이미 말한다.
if (variables is not null && converted.UnknownTokens.Count > 0)
{
notes.Add("아직 옮기지 않은 변수(NULL 로 대체): " + string.Join(", ", converted.UnknownTokens));
}
if (converted.HasLeftover)
{
// 접두어가 클래스 전체 이름이 아니면 토큰이 그대로 남아 ORA 구문오류가 된다 —
// 오라클 문구보다 이쪽이 고칠 곳을 알려 준다.
notes.Add("치환되지 않은 변수가 남았습니다(접두어가 클래스 전체 이름이어야 합니다): "
+ string.Join(", ", converted.Hits
.Where(h => h.Kind == SheetMe.Core.Catalog.QuerySubstitutionKind.NotAVariable)
.Select(h => h.Token).Distinct()));
}
foreach (var bare in BareVariables(SqlBox.Text))
{
// 값은 원문 그대로 박힌다 — 문자 컬럼과 비교하려면 서식 쪽에서 따옴표를 감싸야 한다
// (clsMDataTable.vb:113). 이게 이 배선의 가장 흔한 실수다.
notes.Add($"{bare} 가 따옴표 밖에 있습니다 — 문자 컬럼과 비교하려면 '<<…>>' 로 감싸야 합니다");
}
if (result.Ok) if (result.Ok)
{ {
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink"); TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource(
// 행이 0인 것과 실패는 다르다 — 구문은 맞는데 조건에 걸리는 데이터가 없을 뿐이다. notes.Count > 0 ? "B.Danger" : "B.Ink");
// 치환 변수를 빈 값으로 바꿔 실행하므로 조건이 빡빡하면 0행이 정상이다. var head = result.Rows.Count == 0
TrialSummary.Text = result.Rows.Count == 0 ? result.Summary + (variables is null
? result.Summary + " — 구문은 정상입니다. 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이 없을 수 있습니다." ? " — 구문은 정상입니다(변수가 NULL 이라 조건에 걸리는 행이 없습니다)."
: " — 구문은 정상이고, 이 환자에게 해당하는 행이 없습니다.")
: result.Summary; : result.Summary;
// 빈 조각은 버린다 — 안 버리면 "성공 · · 경고" 처럼 가운뎃점이 떠 있는다
TrialSummary.Text = string.Join(" · ",
new[] { head, basis }.Concat(notes).Where(s => s.Length > 0));
// 0행이어도 표를 띄운다 — <b>컬럼 이름</b>은 이미 알고 있고, 그것이 배선 필드 칸에
// 옮겨 적을 값이다. 표를 접으면 방금 조회한 컬럼명을 다시 알아내려고
// 쿼리를 또 돌려야 한다(0행이 배선 작업에서 드문 상태도 아니다).
TrialGrid.ItemsSource = ToGridRows(result); TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed; TrialGrid.Visibility = result.Columns.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
} }
else else
{ {
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger"); TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Danger");
TrialSummary.Text = "실패 — " + result.Error; TrialSummary.Text = string.Join(" · ",
new[] { "실패 — " + result.Error, basis }.Concat(notes).Where(s => s.Length > 0));
TrialGrid.ItemsSource = null; TrialGrid.ItemsSource = null;
TrialGrid.Visibility = Visibility.Collapsed; TrialGrid.Visibility = Visibility.Collapsed;
// 실패했으면 표가 어차피 없다 — 그 자리에 돌린 문장을 바로 보여 준다.
// 오라클 오류 문구만으로는 어디가 틀렸는지 알 수 없고, 답은 치환된 문장에 있다.
SetTrialSqlVisible(true);
} }
} }
/// <summary>
/// 따옴표 밖에 놓인 치환 변수들 — 문자 컬럼과 비교할 때 값이 그대로 박혀 깨진다.
///
/// 판정은 <b>같은 줄에서 토큰 앞뒤에 붙은 글자</b>만 본다. 정확한 SQL 파싱이 아니라
/// 흔한 실수를 짚는 것이고, 애매하면 말하지 않는다 — 틀린 경고를 자주 내면 아무도 안 읽는다.
/// </summary>
private static List<string> BareVariables(string sql)
{
var found = new List<string>();
foreach (var token in SqlTokenizer.VariablesIn(sql).Distinct(StringComparer.Ordinal))
{
var at = sql.IndexOf(token, StringComparison.Ordinal);
if (at < 0)
{
continue;
}
// 경계를 '따옴표 없음'으로 본다. 처음엔 경계를 건너뛰게 썼는데,
// 그러면 <b>WHERE 절 끝에 온 변수</b>가 전부 빠졌다 — 가장 흔한 자리다.
var before = at > 0 ? sql[at - 1] : '\0';
var after = at + token.Length < sql.Length ? sql[at + token.Length] : '\0';
if (before != '\'' && after != '\'')
{
found.Add(token);
}
}
return found;
}
/// <summary> /// <summary>
/// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다. /// 결과를 DataGrid 가 열까지 만들어 주는 형태로 옮긴다.
/// ///
@@ -604,6 +751,67 @@ public partial class QueryEditorWindow : Window
LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄"; LengthText.Text = $"{text.Length:N0}자 · {CountLines(text)}줄";
UpdateCaretText(); UpdateCaretText();
UpdateProblems(text); UpdateProblems(text);
RefreshTrialFreshness();
}
/// <summary>
/// 검증 결과가 <b>지금 SQL 의 것인지</b> 표시한다.
///
/// 전에는 검증을 돌린 뒤 SQL 을 고쳐도 초록 요약과 결과 표가 그대로 남았다.
/// "검증했다"고 믿고 저장하는데, 저장되는 것은 <b>검증하지 않은 문장</b>이다.
/// 결과를 지우지는 않는다 — 방금 본 컬럼명이 사라지면 그것대로 불편하다.
/// 흐리게 낮추고 다시 돌리라고 말하는 것으로 충분하다.
/// </summary>
private void RefreshTrialFreshness()
{
if (TrialPane is null || TrialPane.Visibility != Visibility.Visible || trialBaseline.Length == 0)
{
return;
}
var stale = !string.Equals(trialBaseline, SqlBox.Text, StringComparison.Ordinal);
TrialPane.Opacity = stale ? 0.5 : 1.0;
if (stale && !TrialSummary.Text.StartsWith(StaleMark, StringComparison.Ordinal))
{
TrialSummary.Text = StaleMark + TrialSummary.Text;
}
else if (!stale && TrialSummary.Text.StartsWith(StaleMark, StringComparison.Ordinal))
{
TrialSummary.Text = TrialSummary.Text[StaleMark.Length..];
}
}
/// <summary>검증 이후 SQL 이 바뀌었을 때 요약 앞에 붙는 표시</summary>
private const string StaleMark = "(수정됨 — 다시 실행하세요) ";
/// <summary>
/// '돌린 SQL' 접기/펴기 — 치환을 마친 문장을 창 안에서 확인한다.
///
/// 0행이 나왔을 때 <b>치환이 NULL 이라 조건이 사라진 것</b>인지
/// <b>이 환자에게 자료가 없는 것</b>인지 가르는 유일한 수단이다. 그 답은 돌린 문장에 있다.
/// 전에는 그 문장을 필드에 담아 두기만 하고(진단용) 화면에는 내보내지 않았다.
/// </summary>
private void OnToggleTrialSql(object sender, RoutedEventArgs e)
{
var show = TrialSqlPane.Visibility != Visibility.Visible;
SetTrialSqlVisible(show);
}
private void SetTrialSqlVisible(bool show)
{
TrialSqlPane.Visibility = show ? Visibility.Visible : Visibility.Collapsed;
ShowTrialSqlButton.Content = show ? "돌린 SQL 숨기기" : "돌린 SQL 보기";
if (show)
{
TrialSqlText.Text = lastTrialSql;
}
}
private void OnCopyTrialSql(object sender, RoutedEventArgs e)
{
if (lastTrialSql.Length > 0)
{
Clipboard.SetText(lastTrialSql);
}
} }
/// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary> /// <summary>줄·열 표시 — 오류 메시지의 위치를 찾아갈 때 쓴다</summary>
@@ -915,6 +1123,35 @@ public partial class QueryEditorWindow : Window
#endregion #endregion
#region Methods - Variables #region Methods - Variables
/// <summary>
/// 변수 검색칸의 Enter·↓ — <b>확인 버튼으로 새어 나가는 것을 막는다</b>.
///
/// 확인 버튼이 <c>IsDefault</c> 라, 이 칸에서 Enter 를 누르면 검색이나 삽입이 아니라
/// <b>SQL 이 저장되고 창이 닫혔다</b>. '차트' 를 치고 Enter 를 눌렀을 뿐인데
/// 변수를 하나도 못 넣은 채 편집이 끝난다 — 무엇이 일어났는지도 알기 어렵다.
///
/// Enter 는 지금 걸러진 목록의 선택 항목을 넣는다(필터가 하나로 좁혀졌을 때가 대부분이다).
/// ↓ 는 목록으로 이동한다 — 손을 마우스로 옮기지 않고 훑을 수 있어야 한다.
/// </summary>
private void OnVariableSearchKeyDown(object sender, KeyEventArgs e)
{
if (e.Key == Key.Enter)
{
InsertSelectedVariable();
e.Handled = true; // IsDefault 버튼에 도달시키지 않는다
return;
}
if (e.Key == Key.Down && VariableList.Items.Count > 0)
{
VariableList.Focus();
if (VariableList.SelectedIndex < 0)
{
VariableList.SelectedIndex = 0;
}
e.Handled = true;
}
}
private void OnVariableSearchChanged(object sender, TextChangedEventArgs e) private void OnVariableSearchChanged(object sender, TextChangedEventArgs e)
{ {
SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed; SearchPlaceholder.Visibility = VariableSearch.Text.Length == 0 ? Visibility.Visible : Visibility.Collapsed;
@@ -3,7 +3,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="신규 서식 등록" Width="420" SizeToContent="Height" Title="신규 서식 등록" Width="420" SizeToContent="Height"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize" WindowStartupLocation="CenterOwner" ShowInTaskbar="False" ResizeMode="NoResize"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<StackPanel Margin="16"> <StackPanel Margin="16">
<TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다." <TextBlock Text="E_ShtMst 에 등록되지 않은 서식입니다. 신규 등록 후 저장합니다."
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/> TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,12"/>
@@ -26,7 +26,7 @@
TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/> TextWrapping="Wrap" Foreground="{DynamicResource B.Muted}" FontSize="11" Margin="0,10,0,0"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0"> <StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,14,0,0">
<Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True"/> <Button Content="등록 후 저장" Padding="14,5" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
<Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/> <Button Content="취소" Padding="14,5" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
</StackPanel> </StackPanel>
@@ -39,6 +39,49 @@
</ItemsControl.ItemTemplate> </ItemsControl.ItemTemplate>
</ItemsControl> </ItemsControl>
<!--
잠긴 컨트롤 표시 — 편집 크롬이므로 종이(PageView)에는 없다.
그쪽에 넣으면 db-render md5 가 깨진다(레거시와의 픽셀 동일성을 잠그는 게이트다).
숨김은 0.25 투명으로 티가 나는데 잠금은 아무 표시가 없어,
안 잡히는 이유를 화면에서 알 방법이 없었다.
-->
<ItemsControl ItemsSource="{Binding LockMarks}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<Canvas/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemContainerStyle>
<Style TargetType="ContentPresenter">
<Setter Property="Canvas.Left" Value="{Binding X}"/>
<Setter Property="Canvas.Top" Value="{Binding Y}"/>
</Style>
</ItemsControl.ItemContainerStyle>
<ItemsControl.ItemTemplate>
<DataTemplate>
<Rectangle Width="{Binding W}" Height="{Binding H}"
Stroke="#8C8C8C" StrokeThickness="1" StrokeDashArray="2 2"
Fill="Transparent" Opacity="0.7"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<!--
끄는 동안의 치수·좌표 — 편집 크롬이라 종이에는 없다.
줌이 걸린 층이라 역배율로 되돌려 글자 크기를 화면 고정으로 만든다
(간격 수치 칩이 이미 같은 방식을 쓴다).
-->
<Border Canvas.Left="{Binding DragBadgeX}" Canvas.Top="{Binding DragBadgeY}"
Background="{DynamicResource B.AccentFill}" CornerRadius="3" Padding="5,1"
RenderTransformOrigin="0,0"
Visibility="{Binding ShowDragBadge, Converter={StaticResource BoolToVisibility}}">
<Border.RenderTransform>
<ScaleTransform ScaleX="{Binding InverseZoom}" ScaleY="{Binding InverseZoom}"/>
</Border.RenderTransform>
<TextBlock Text="{Binding DragBadgeText}" FontSize="11"
Foreground="{DynamicResource B.OnAccent}"/>
</Border>
<!-- 마퀴(러버밴드) --> <!-- 마퀴(러버밴드) -->
<Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}" <Rectangle Canvas.Left="{Binding MarX}" Canvas.Top="{Binding MarY}"
Width="{Binding MarW}" Height="{Binding MarH}" Width="{Binding MarW}" Height="{Binding MarH}"
@@ -3,14 +3,14 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380" Title="서식 수정이력" Width="560" Height="520" MinWidth="480" MinHeight="380"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<DockPanel Margin="12"> <DockPanel Margin="16">
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" <TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/> TextWrapping="Wrap" Margin="0,0,0,8"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0"> <StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/> <TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True" <Button Content="열람(새 탭)" Padding="16,4" Click="OnOpen" IsDefault="True" Style="{StaticResource Primary}"
ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/> ToolTip="선택한 버전을 새 탭으로 엽니다 — 열람 후 'DB에 저장'하면 그 내용이 새 활성 버전이 됩니다(복원)"/>
<Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/> <Button Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel> </StackPanel>
@@ -4,12 +4,13 @@
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls" xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
Title="태그 선택" Width="1080" Height="700" MinWidth="900" MinHeight="560" Title="태그 선택" Width="1080" Height="700" MinWidth="900" MinHeight="560"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False" WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}"> Style="{StaticResource ThemedDialogWindow}">
<!-- <!--
창 껍데기는 OS 크롬을 유지한다. 목업의 '오른쪽 위 X + 둥근 카드'는 WindowStyle=None 이 필요한데, 창 껍데기는 ThemedDialogWindow 가 준다 — 오른쪽 위 X 를 단 자체 타이틀바다.
이 앱의 대화상자 10종이 전부 OS 크롬이라 이 창만 바꾸면 크롬 규약이 두 벌이 된다. (예전 주석은 "이 앱의 대화상자 10종이 전부 OS 크롬이라 이 창만 바꾸면 규약이 두 벌이 된다"였다.
그 뒤 열한 창을 <b>전부</b> 자체 크롬으로 옮겼으므로 그 전제는 더 이상 사실이 아니다.)
AllowsTransparency 는 ClearType 이 꺼져 13px 한글이 뭉개져서 쓰지 않는다(MessageDialogView 주석 참조). AllowsTransparency 는 ClearType 이 꺼져 13px 한글이 뭉개져서 쓰지 않는다(MessageDialogView 주석 참조).
둥근 모서리와 그림자는 DWM 이 이미 그린다. 그림자는 DWM 이 그린다.
--> -->
<Window.Resources> <Window.Resources>
<ctl:IconNameToVisualConverter x:Key="IconName"/> <ctl:IconNameToVisualConverter x:Key="IconName"/>
@@ -64,6 +64,9 @@ public partial class TagPickerDialogView : Window
/// </summary> /// </summary>
private readonly IReadOnlyList<string> preferred; private readonly IReadOnlyList<string> preferred;
/// <summary>액션 태그 목록인가 — 값 칸의 뜻이 데이터 태그와 완전히 다르다</summary>
private readonly bool isActionKind;
private const string FilterAll = "*"; private const string FilterAll = "*";
private const string FilterPreferred = "!"; private const string FilterPreferred = "!";
private const string FilterUsed = "+"; private const string FilterUsed = "+";
@@ -81,11 +84,16 @@ public partial class TagPickerDialogView : Window
#endregion #endregion
#region Constructors #region Constructors
/// <param name="isActionTag">
/// 액션 태그 목록인가 — 액션은 <b>값이 아니라 동작</b>이라 값·사유 칸의 뜻이 완전히 다르다.
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 오른쪽 설명이 거짓이 된다.
/// </param>
public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue, public TagPickerDialogView(string title, IReadOnlyList<string> tags, string? currentValue,
IReadOnlyList<string>? preferredTags = null) IReadOnlyList<string>? preferredTags = null, bool isActionTag = false)
{ {
allTags = tags; allTags = tags;
preferred = preferredTags ?? Array.Empty<string>(); preferred = preferredTags ?? Array.Empty<string>();
isActionKind = isActionTag;
previewStore = new SheetMe.Data.Stores.TagPreviewStore( previewStore = new SheetMe.Data.Stores.TagPreviewStore(
Services.ConfigService.Current.ConnectionString); Services.ConfigService.Current.ConnectionString);
InitializeComponent(); InitializeComponent();
@@ -295,13 +303,21 @@ public partial class TagPickerDialogView : Window
/// </summary> /// </summary>
private void ShowTagValue(string tag) private void ShowTagValue(string tag)
{ {
if (previewStore is null || !previewStore.CanUseDb)
{
InfoValueBlock.Visibility = Visibility.Collapsed;
return;
}
InfoValueBlock.Visibility = Visibility.Visible; InfoValueBlock.Visibility = Visibility.Visible;
// 액션 태그는 <b>값이 아니다</b>. 더블클릭·버튼이 실행하는 동작이라
// "환자 정보가 있어야 값이 나옵니다" 같은 데이터 태그용 사유를 붙이면
// 149종 전부에서 이 칸이 통째로 거짓말을 한다.
if (isActionKind)
{
InfoValue.Text = "값이 아니라 동작입니다";
InfoValueNote.Text = "더블클릭·버튼에서 실행됩니다 — 종이에 찍히는 값이 아닙니다.";
return;
}
// 값을 <b>만들 수 없는 이유</b>는 DB 없이도 안다(정적 판정이다).
// 전에는 DB 가드가 이 위에 있어서, 접속이 없으면 사유까지 통째로 감춰졌다 —
// 정작 그 사유가 가장 필요한 상황(접속 안 된 단말에서 서식을 훑을 때)에 안 보였다.
if (!TagPreviewCatalog.CanResolve(tag)) if (!TagPreviewCatalog.CanResolve(tag))
{ {
InfoValue.Text = "값을 만들 수 없습니다"; InfoValue.Text = "값을 만들 수 없습니다";
@@ -309,12 +325,29 @@ public partial class TagPickerDialogView : Window
return; return;
} }
// 여기서부터는 실제 값이 필요하다 — 그때만 DB 를 따진다.
// (진단 스크린샷은 DB 없이 이 창을 띄우므로 이 가드가 없으면 늘 "값을 읽지 못했습니다"가 찍힌다)
if (previewStore is null || !previewStore.CanUseDb)
{
InfoValue.Text = "값은 접속된 단말에서 확인됩니다";
InfoValueNote.Text = TagPreviewCatalog.SourceText(tag);
return;
}
previewContext ??= previewStore.Read(UserSession.Current.UidCod, UserSession.Current.UidNam); previewContext ??= previewStore.Read(UserSession.Current.UidCod, UserSession.Current.UidNam);
var context = previewContext.Value; var context = previewContext.Value;
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
? TagPreviewCatalog.FromClock(tag, context.Minute12) ? TagPreviewCatalog.FromClock(tag, context.Minute12)
// <b>본판(10-인자)으로 부른다.</b> 5-인자 위임판은 면허·전문의·직종·부서·부서전화를
// 빈 문자열로 채운다 — 그래서 의사면허번호·전문의번호·직종·부서·부서전화 계열이
// 피커에서는 "이 계정에는 값이 비어 있습니다"로 보이는데 종이에는 실제 값이 찍혔다.
// 값이 나오는 사용자 태그 중 다섯 갈래가 <b>피커에서만</b> 거짓말을 하고 있었다.
// 이 값들은 로그인 세션이 이미 들고 있다(HisUser 15컬럼) — 조회를 더 하지 않는다.
: TagPreviewCatalog.FromUser(tag, context.UidCod, context.UidNam, : TagPreviewCatalog.FromUser(tag, context.UidCod, context.UidNam,
context.MobilePhone, context.OfficePhone); context.MobilePhone, context.OfficePhone,
UserSession.Current.LicenseNumber, UserSession.Current.SpecialistNumber,
UserSession.Current.JobName, UserSession.Current.DepNam,
UserSession.Current.DepPhone);
if (value.Length > 0) if (value.Length > 0)
{ {
+5
View File
@@ -11,5 +11,10 @@
"Designer": { "Designer": {
"GridSize": 4, "GridSize": 4,
"SnapThreshold": 6 "SnapThreshold": 6
},
"Images": {
"//": "싸인·직인 이미지를 파일서버 공유(UNC)에서 직접 읽기 위한 경로 대응. DB 는 서버측 절대경로(예: D:\\MsystechHIS\\002\\...)를 들고 있고, 레거시는 그 경로를 ActiveX 전송 데몬으로 받아 온다 — 우리는 같은 파일을 공유로 읽는다. 병원마다 접두가 다르므로 설치 시 채운다. 진단 --db-filecfg 가 현재 상태와 필요한 설정을 알려 준다.",
"//예시": "\"SignatureShareMap\": [ \"D:\\\\MsystechHIS=\\\\\\\\192.168.100.222\\\\MsystechHIS\" ] 또는 한 줄로 \"D:\\\\MsystechHIS=\\\\\\\\서버\\\\공유;E:\\\\msys=\\\\\\\\서버\\\\msys\"",
"SignatureShareMap": []
} }
} }
@@ -0,0 +1,237 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 여러 컨트롤을 함께 고쳤을 때 <b>레거시가 못 여는 XML</b>이 나오지 않는지 고정한다.
///
/// 여기서 막는 것들은 전부 화면으로 확인할 수 없는 손상이다 —
/// 디자이너는 멀쩡해 보이고, EMR 에서만 다르게 나오거나 서식이 아예 안 열린다.
/// </summary>
[TestClass]
public class BatchEditPolicyTests
{
#region Helpers
private static ControlElement Element(string type, string id = "c")
=> new() { Id = id, Type = type };
private static IReadOnlyList<string> KeysOf(IReadOnlyList<string> types)
=> BatchEditPolicy.MergeCommonDefs(types).Select(d => d.Key).ToList();
#endregion
#region Tests -
[TestMethod]
public void _하나면_그_타입의_속성을_그대로_준다()
{
var label = ControlRegistry.Find("Label");
Assert.IsNotNull(label);
CollectionAssert.AreEqual(
label.Properties.Select(d => d.Key).ToList(),
KeysOf(new[] { "Label" }).ToList());
}
[TestMethod]
public void _선택지가_달라_공통에_오지_않는다()
{
// 라벨은 ContentAlignment 9값(TopLeft…), 텍스트박스는 HorizontalAlignment 3값(Left/Center/Right).
// 키 이름만 비교하는 구현으로 되돌아가면 "MiddleCenter" 가 텍스트박스에 들어가고,
// 그 값은 레거시가 읽지 못한다.
CollectionAssert.DoesNotContain(KeysOf(new[] { "Label", "TextBox" }).ToList(), "TextAlign");
}
[TestMethod]
public void _키는_대소문자로_갈린다()
{
// 라벨만 소문자 visible 이다(레거시가 그림자 속성을 쓴다). 별칭으로 묶으면
// 라벨에 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
var mixed = KeysOf(new[] { "Label", "TextBox" }).ToList();
CollectionAssert.DoesNotContain(mixed, "visible");
CollectionAssert.DoesNotContain(mixed, "Visible");
}
[TestMethod]
public void _모르는_타입이_섞이면_공통이_없다()
{
// 무엇이 공통인지 말할 근거가 없다 — 추측해서 열면 그게 손상 경로가 된다
Assert.AreEqual(0, KeysOf(new[] { "Label", "Spread" }).Count);
}
[TestMethod]
public void _정의를_가진_공통_속성은_병합된다()
{
// 공통 7종은 `=> new()` 식 본문 프로퍼티라 접근할 때마다 새 인스턴스다.
// def 참조를 비교하는 구현이면 전부 '다르다'가 나와 이 검사가 깨진다.
CollectionAssert.Contains(KeysOf(new[] { "Label", "TextBox" }).ToList(), "PrintOutPut");
}
[TestMethod]
public void _없으면_빈_집합이다()
{
Assert.AreEqual(0, BatchEditPolicy.MergeCommonDefs(Array.Empty<string>()).Count);
}
[TestMethod]
public void _못_올린_것의_개수를_센다()
{
// 조용히 빼면 사용자는 그 속성이 존재하지 않는다고 읽는다 — 개수를 알려 줘야 한다
Assert.IsTrue(BatchEditPolicy.ExcludedCount(new[] { "Label", "TextBox" }) > 0);
Assert.AreEqual(0, BatchEditPolicy.ExcludedCount(new[] { "Label" }));
}
#endregion
#region Tests -
[TestMethod]
public void _하나면_아무것도_막지_않는다()
{
// 이 기능이 기존 단일 선택 동작을 좁히면 안 된다
var one = new[] { Element("Label") };
foreach (var key in new[] { "Name", "SignatureIndex", "Formula", "SequenceCode", "TextAlign" })
{
Assert.IsTrue(BatchEditPolicy.IsBatchable(key, one).Allowed, key);
}
}
[TestMethod]
public void _하는_키는_함께_못_바꾼다()
{
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "Name", "SignatureIndex", "DisplaySequence", "TabIndex", "LinkToNewDesign" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _이름으로_가리키는_배선은_함께_못_바꾼다()
{
var two = new[] { Element("RadioButton", "a"), Element("RadioButton", "b") };
foreach (var key in new[] { "Formula", "DataActionTagControl", "AssociatedControl", "EventHandlerMappingTag" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
// 액션 태그 <b>자체</b>는 정상 작업이다 — 같은 문항의 라디오에 같은 액션이 붙는다
Assert.IsTrue(BatchEditPolicy.IsBatchable("DataActionTag", two).Allowed);
}
[TestMethod]
public void _발급은_함께_못_바꾼다()
{
// 서식을 여는 것만으로 DB 카운터가 오른다 — Undo 로 덮이지 않는 유일한 부류다
var two = new[] { Element("Label", "a"), Element("Label", "b") };
foreach (var key in new[] { "SequenceCode", "SequenceType", "SequenceNumber", "CheckSequenceAtSaving" })
{
Assert.IsFalse(BatchEditPolicy.IsBatchable(key, two).Allowed, key);
}
}
[TestMethod]
public void _타입이_섞였을_때만_막는다()
{
var sameType = new[] { Element("TextBox", "a"), Element("TextBox", "b") };
var mixedType = new[] { Element("Label", "a"), Element("TextBox", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("TextAlign", sameType).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("TextAlign", mixedType).Allowed);
}
[TestMethod]
public void _방향은_원문_경로에서만_막는다()
{
// 전용 편집기는 컨트롤마다 크기까지 함께 고치므로(ApplyLineChange) 다중 선택에서도 옳다.
// 원문 텍스트 행은 속성만 써서 XML 은 Vertical 인데 경계는 가로인 선을 만든다.
var lines = new[] { Element("Line", "a"), Element("Line", "b") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Orientation", lines).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("Orientation", lines, viaAdvancedRaw: true).Allowed);
Assert.IsFalse(BatchEditPolicy.IsBatchable("BorderWidth", lines, viaAdvancedRaw: true).Allowed);
}
[TestMethod]
public void _묶음의_라디오는_기본_선택을_함께_못_바꾼다()
{
// 레거시 라디오는 GroupName 이 없어 직계 부모가 문항이다. 둘 다 켜면 한 문항의 답이 둘이 되고,
// 로더가 부모에 붙이기 전에 값을 대입하므로 열 때 정리되지도 않는다.
var parent = Element("Panel", "p");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var targets = new[] { first, second };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _다른_라디오는_함께_바꿀_수_있다()
{
var left = Element("Panel", "p1");
var right = Element("Panel", "p2");
var first = Element("RadioButton", "r1");
var second = Element("RadioButton", "r2");
var allowed = BatchEditPolicy.IsBatchable("Checked", new[] { first, second },
parentOf: element => ReferenceEquals(element, first) ? left : right).Allowed;
Assert.IsTrue(allowed);
}
[TestMethod]
public void _배타가_없어_함께_바꿀_수_있다()
{
var parent = Element("Panel", "p");
var targets = new[] { Element("CheckBox", "c1"), Element("CheckBox", "c2") };
Assert.IsTrue(BatchEditPolicy.IsBatchable("Checked", targets, parentOf: _ => parent).Allowed);
}
[TestMethod]
public void _모르면_라디오를_막는다()
{
// 모르면 막는 쪽이다 — 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고
// 그것은 화면으로 확인할 수 없다
var targets = new[] { Element("RadioButton", "r1"), Element("RadioButton", "r2") };
Assert.IsFalse(BatchEditPolicy.IsBatchable("Checked", targets).Allowed);
}
#endregion
#region Tests - ()
[TestMethod]
public void _갖고_전부_문자열일_때만_원문을_연다()
{
var first = Element("Label", "a");
var second = Element("Label", "b");
first.Props.SetText("Tooltip", "가");
second.Props.SetText("Tooltip", "나");
Assert.IsTrue(BatchEditPolicy.AllShareTextShape("Tooltip", new[] { first, second }));
}
[TestMethod]
public void _형태가_다르면_원문을_열지_않는다()
{
// 이미지 원문(BinaryValue)을 문자열로 덮으면 복구할 수 없다.
// GetText 가 문자열 아닌 값에 null 을 주므로 빈 값과 이미지가 둘 다 "" 로 접혀
// <b>갈렸다는 표시조차</b> 안 나온다 — 그래서 형태로 판정한다.
var text = Element("PictureBox", "a");
var binary = Element("PictureBox", "b");
text.Props.SetText("Image", string.Empty);
binary.Props.Set("Image", new LegacyPropValue.BinaryValue { Base64 = "AQID" });
// 어느 쪽을 먼저 골랐든 결과가 같아야 한다 — 클릭 순서로 안전성이 갈리면 안 된다
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { text, binary }));
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Image", new[] { binary, text }));
}
[TestMethod]
public void _가진_키는_원문을_열지_않는다()
{
// 없던 대상에 키가 생기면 레거시 서식생성기가 열 때마다 오류 모달을 띄운다
var has = Element("Label", "a");
var lacks = Element("Label", "b");
has.Props.SetText("Score", "5");
Assert.IsFalse(BatchEditPolicy.AllShareTextShape("Score", new[] { has, lacks }));
Assert.AreEqual(1, BatchEditPolicy.CountHaving("Score", new[] { has, lacks }));
}
#endregion
}
@@ -0,0 +1,73 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 배선 한 칸 선택 — Select 형 필터는 .NET DataTable.Select 를 그대로 쓰므로
/// 여기서는 "같은 엔진에 제대로 넘겼는가"(필터 적용·행 선택·사유)를 본다.
/// </summary>
[TestClass]
public sealed class DataTableFieldPickerTests
{
#region Methods
private static readonly string[] Columns = { "NAM", "TYP" };
private static readonly string[][] Rows =
{
new[] { "감기", "M" },
new[] { "골절", "S" },
new[] { "당뇨", "S" },
};
private static DataTableFieldSpec Select(string filter, int row, string field = "NAM")
=> new()
{
TableName = "T", Form = DataTableFieldForm.Select,
Filter = filter, RowIndex = row, Field = field,
};
[TestMethod]
public void Select_필터가_행을_거른다()
{
// TYP='S' 인 행이 둘 — 0번은 골절, 1번은 당뇨
Assert.AreEqual("골절", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 0)).Text);
Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'S'", 1)).Text);
}
[TestMethod]
public void Select_필터에_맞는_행이_없으면_사유다()
{
var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP = 'X'", 0));
Assert.IsFalse(value.Resolved);
StringAssert.Contains(value.Text, "맞는 행이 없습니다");
}
[TestMethod]
public void Select_필터_문법이_깨지면_사유다()
{
// 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다 — 여기서는 사유가 나온다
var value = DataTableFieldPicker.Pick(Columns, Rows, Select("TYP === ", 0));
Assert.IsFalse(value.Resolved);
StringAssert.Contains(value.Text, "해석할 수 없습니다");
}
[TestMethod]
public void Rows_형은_행_번호로_고른다()
{
var spec = new DataTableFieldSpec
{
TableName = "T", Form = DataTableFieldForm.Rows, RowIndex = 2, Field = "NAM",
};
Assert.AreEqual("당뇨", DataTableFieldPicker.Pick(Columns, Rows, spec).Text);
}
[TestMethod]
public void _컬럼명은_Fill_규칙으로_비껴_담고_첫_컬럼과_매칭된다()
{
// SELECT * 조인에서 같은 이름이 두 번 오는 경우 — 첫 컬럼의 값이 나와야 한다
var dup = new[] { "NAM", "NAM" };
var rows = new[] { new[] { "첫값", "둘값" } };
Assert.AreEqual("첫값", DataTableFieldPicker.Pick(dup, rows, Select("NAM = '첫값'", 0)).Text);
}
#endregion
}
@@ -5,9 +5,9 @@ namespace SheetMe.Core.Tests;
/// <summary> /// <summary>
/// 환자 미리보기 접근 규칙 고정. /// 환자 미리보기 접근 규칙 고정.
/// ///
/// 이 표가 느슨해지면 서식을 만드는 사람 누구나 <b>운영 환자 기록</b>을 열 수 있다. /// 레거시는 인가코드가 소스에 평문이고, 검색 범위 무제한이고, 감사 0건이었다.
/// 레거시가 그 상태였다 — 인가코드가 소스에 평문이고, 검색 범위 무제한이고, 감사 0건이다. /// 검색 범위와 접근 범위는 레거시와 같게 두기로 했으므로 <b>감사 로그가 유일한 장치</b>다 —
/// 검색 범위는 레거시와 같게 두기로 했으므로 <b>감사와 권한</b>이 유일한 장치다. /// 그래서 감사 한 줄의 모양(주체가 있는가, 이름이 새지 않는가)이 여기서 가장 중요한 판정이다.
/// </summary> /// </summary>
[TestClass] [TestClass]
public sealed class PatientPreviewPolicyTests public sealed class PatientPreviewPolicyTests
@@ -23,13 +23,30 @@ public sealed class PatientPreviewPolicyTests
} }
[TestMethod] [TestMethod]
public void _권한과_같은_축을_쓴() public void _막지_않는()
{ {
// 환자 기록을 보는 일은 지금 쓰이는 서식을 제자리에서 고치는 일과 무게가 다르지 않다. // 처음에는 수정 권한(ModifyPolicy)을 그대로 갖다 썼다. 그건 레거시에 없는 제약이었다 —
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("ABC", "EDPS", "011825", string.Empty)); // 레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이고 TK_PREVIEW 에는 문이 없다
Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("ABC", "NURS", "011825", string.Empty)); // (frmSheetDesigner.vb:1030-1060). 서식을 만드는 사람이 자기 서식을 검증할 수 없으면
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("HANKOOK", "SALO", "011825", string.Empty)); // 값이 틀린 서식이 그대로 운영에 올라간다.
Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("XYZ", "EDPS", "011825", "20250501")); foreach (var dep in new[] { "EDPS", "NURS", "SALO", "MEDI", "" })
{
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("ABC", dep, "011825", string.Empty),
$"부서 '{dep}' 로 막혔다");
}
// 수정에만 걸리던 갈래도 미리보기에는 걸리지 않는다
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("XYZ", "NURS", "011825", "20250501"));
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("HANKOOK", "NURS", "011825", string.Empty));
}
[TestMethod]
public void _권한은_그대로_좁게_남는다()
{
// 미리보기를 연 것이 수정까지 연 것이 되면 안 된다 — 수정은 지금 쓰이는 서식을
// 제자리에서 바꿔 병원 전체에 즉시 영향을 준다. 이 대조가 없으면 위 완화가
// 조용히 ModifyPolicy 까지 번져도 알 수 없다.
Assert.IsFalse(ModifyPolicy.CanModify("ABC", "NURS", "011825", string.Empty));
Assert.IsTrue(ModifyPolicy.CanModify("ABC", "EDPS", "011825", string.Empty));
} }
[TestMethod] [TestMethod]
@@ -0,0 +1,164 @@
using Microsoft.VisualStudio.TestTools.UnitTesting;
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
namespace SheetMe.Core.Tests;
/// <summary>
/// 미리보기 클릭 — 라디오 배타가 <b>직계 부모 안에서만</b> 일어나는지가 핵심이다.
/// 레거시 RadioButton 은 GroupName 이 없어 부모 컨테이너가 문항 단위이고,
/// 운영 서식의 라디오 98.96% 가 그 형태다.
/// </summary>
[TestClass]
public class PreviewCheckStateTests
{
#region Helpers
private static ControlElement Radio(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "RadioButton" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
private static ControlElement Check(string id, bool checkedOn = false)
{
var element = new ControlElement { Id = id, Type = "CheckBox" };
element.Props.SetText("Checked", checkedOn ? "True" : "False");
return element;
}
#endregion
#region Tests
[TestMethod]
public void _않았으면_서식의_기본값을_그대로_읽는다()
{
var state = new PreviewCheckState();
Assert.IsTrue(state.IsChecked(Radio("a", checkedOn: true)));
Assert.IsFalse(state.IsChecked(Radio("b")));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void _누르면_같은_부모의_다른_라디오가_꺼진다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var third = Radio("c");
var siblings = new[] { first, second, third };
var state = new PreviewCheckState();
state.Click(second, siblings);
Assert.IsFalse(state.IsChecked(first));
Assert.IsTrue(state.IsChecked(second));
Assert.IsFalse(state.IsChecked(third));
}
[TestMethod]
public void _부모의_라디오는_영향을_받지_않는다()
{
// 패널 1개 = 문항 1개. 옆 문항의 답이 지워지면 그 서식은 못 쓴다.
var mine = Radio("a");
var neighbour = Radio("z", checkedOn: true);
var state = new PreviewCheckState();
state.Click(mine, new[] { mine, Radio("b") });
Assert.IsTrue(state.IsChecked(neighbour));
}
[TestMethod]
public void _다시_눌러도_꺼지지_않는다()
{
// WinForms 라디오는 자기를 눌러 해제하는 동작이 없다 — 끄는 것은 형제를 누르는 것뿐이다.
var radio = Radio("a");
var state = new PreviewCheckState();
state.Click(radio, new[] { radio });
state.Click(radio, new[] { radio });
Assert.IsTrue(state.IsChecked(radio));
}
[TestMethod]
public void _누를_때마다_반전한다()
{
var box = Check("a");
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsTrue(state.IsChecked(box));
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void _형제_라디오를_끄지_않는다()
{
// 한 패널에 체크박스와 라디오가 섞인 서식이 있다(기타 항목 + 자유 입력).
var radio = Radio("r", checkedOn: true);
var box = Check("c");
var state = new PreviewCheckState();
state.Click(box, new[] { radio, box });
Assert.IsTrue(state.IsChecked(radio));
Assert.IsTrue(state.IsChecked(box));
}
[TestMethod]
public void _체크인_체크박스를_누르면_풀린다()
{
var box = Check("a", checkedOn: true);
var state = new PreviewCheckState();
state.Click(box, new[] { box });
Assert.IsFalse(state.IsChecked(box));
}
[TestMethod]
public void _눌리지_않는다()
{
// 레거시는 PreventEditing=True 면 OnClick 을 무시한다 — 값 대입만 가능하다.
var locked = Radio("a");
locked.Props.SetText("PreventEditing", "True");
var mate = Radio("b", checkedOn: true);
var state = new PreviewCheckState();
state.Click(locked, new[] { locked, mate });
Assert.IsFalse(PreviewCheckState.IsClickable(locked));
Assert.IsFalse(state.IsChecked(locked));
Assert.IsTrue(state.IsChecked(mate));
Assert.IsFalse(state.HasAny);
}
[TestMethod]
public void _수_있는_타입은_체크류뿐이다()
{
Assert.IsTrue(PreviewCheckState.IsClickable(Radio("a")));
Assert.IsTrue(PreviewCheckState.IsClickable(Check("b")));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "c", Type = "Label" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "d", Type = "TextBox" }));
Assert.IsFalse(PreviewCheckState.IsClickable(new ControlElement { Id = "e", Type = "Panel" }));
}
[TestMethod]
public void _서식의_기본값으로_돌아간다()
{
var first = Radio("a", checkedOn: true);
var second = Radio("b");
var state = new PreviewCheckState();
state.Click(second, new[] { first, second });
state.Clear();
Assert.IsFalse(state.HasAny);
Assert.IsTrue(state.IsChecked(first));
Assert.IsFalse(state.IsChecked(second));
}
#endregion
}
@@ -0,0 +1,161 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// MDataTable 쿼리 치환 고정 — 레거시 <c>clsMDataTable.ConvertQuery</c> 와 같은 SQL 이 나와야 한다.
///
/// 한 칸이라도 다르면 미리보기가 운영과 <b>다른 SQL</b>을 돌린다. 그러면 "미리보기에서는
/// 나왔는데 실제로는 안 나온다"가 되고, 그건 미리보기가 없는 것보다 나쁘다 —
/// 사람이 확인했다고 믿고 넘어간다.
/// </summary>
[TestClass]
public sealed class QuerySubstitutionTests
{
#region Nested Types
private sealed class Source : IQueryVariableSource
{
public string? Scalar(string className, string property) => (className, property) switch
{
(QuerySubstitution.PatientClass, "ChtNum") => "0012345",
(QuerySubstitution.PatientClass, "ComNum") => "1566794",
(QuerySubstitution.PatientClass, "AdpDtm") => "202608181500",
(QuerySubstitution.PatientClass, "PatTyp") => "O",
// 안다, 값이 비었다
(QuerySubstitution.PatientClass, "EmgKTSGrd") => string.Empty,
(QuerySubstitution.WorkClass, "WrkUid") => "011825",
_ => null,
};
public IReadOnlyDictionary<string, string>? Row(string className, string property)
=> (className, property) switch
{
(QuerySubstitution.PatientClass, "PatInfDR") =>
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PatNam"] = "가상환자", ["PatResNum"] = "8801011234567", ["PatMblPhn"] = string.Empty,
},
_ => null,
};
}
#endregion
#region Methods
private static QuerySubstitutionResult Run(string sql) => QuerySubstitution.Apply(sql, new Source());
[TestMethod]
public void _원문_그대로_박는다()
{
// 따옴표를 붙여 주지 않는다 — SQL 쪽에서 감싸야 한다(clsMDataTable.vb:113)
var r = Run("SELECT * FROM P_ComInf WHERE ComNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>");
Assert.AreEqual("SELECT * FROM P_ComInf WHERE ComNum = 1566794", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Value, r.Hits[0].Kind);
}
[TestMethod]
public void DataRow_두_형태를_모두_받는다()
{
var withItem = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNam\")>>");
Assert.AreEqual("X 가상환자", withItem.Sql);
// 괄호만 쓰는 형태도 정규식이 받는다
var bare = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR(\"PatNam\")>>");
Assert.AreEqual("X 가상환자", bare.Sql);
}
[TestMethod]
public void item_은_대문자면_값이_되지_않고_지워진다()
{
// 정규식이 대소문자를 구분한다(clsMDataTable.vb:112). 이걸 관대하게 만들면
// 레거시에서 안 되는 서식이 미리보기에서만 되고, 현장에서 빈칸으로 나온다.
//
// 지워지는 것이 맞다 — GetPropertyInfo 가 정규식 실패에 ""를 돌려주고(:118)
// 호출부가 그것을 빈 문자열로 치환한다(:66-70). 접두어 불일치와는 <b>다른</b> 갈래다:
// 그쪽은 oBaseObj 가 Nothing 이라 Replace 를 아예 하지 않아 토큰이 남는다.
var r = Run("X <<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.Item(\"PatNam\")>>");
Assert.AreEqual("X ", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Unknown, r.Hits[0].Kind,
"SQL 에서는 사라지지만 사람에게는 '규약 밖 경로'라고 말해야 한다");
Assert.AreEqual(1, r.UnknownTokens.Count);
}
[TestMethod]
public void _접두어는_토큰이_그대로_남는다()
{
// 레거시는 클래스 전체 이름으로만 StartsWith 한다 — 축약형은 SQL 에 남아 ORA 구문오류가 된다.
// 조용히 지우면 조건이 사라진 SQL 이 돌아 엉뚱한 행이 나온다. 남기는 쪽이 안전하다.
var r = Run("SELECT 1 FROM DUAL WHERE X = <<bzPatientInfo.ComNum>>");
StringAssert.Contains(r.Sql, "<<bzPatientInfo.ComNum>>");
Assert.IsTrue(r.HasLeftover);
Assert.AreEqual(QuerySubstitutionKind.NotAVariable, r.Hits[0].Kind);
}
[TestMethod]
public void _속성과_빈_값을_구분한다()
{
// SQL 에서는 둘 다 빈 문자열이다(레거시와 같게) — 사람에게는 달라야 한다.
var unknown = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.Age>>B");
Assert.AreEqual("AB", unknown.Sql);
Assert.AreEqual(QuerySubstitutionKind.Unknown, unknown.Hits[0].Kind);
Assert.AreEqual(1, unknown.UnknownTokens.Count);
var empty = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.EmgKTSGrd>>B");
Assert.AreEqual("AB", empty.Sql);
Assert.AreEqual(QuerySubstitutionKind.Empty, empty.Hits[0].Kind);
Assert.AreEqual(0, empty.UnknownTokens.Count);
}
[TestMethod]
public void _있고_컬럼이_없으면_오타로_본다()
{
// 이 경우는 서식이 컬럼명을 틀린 것이다 — 아직 안 옮긴 것과 구분해야 고칠 곳을 안다.
var r = Run("A<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatNamX\")>>B");
Assert.AreEqual("AB", r.Sql);
Assert.AreEqual(QuerySubstitutionKind.Empty, r.Hits[0].Kind);
}
[TestMethod]
public void _토큰과_사이_글자를_보존한다()
{
var r = Run("WHERE A = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatTyp>>'"
+ " AND B = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
+ " AND C = '<<M.CMM.HISOperatingInfo.bzWorkInfo.WrkUid>>'");
Assert.AreEqual("WHERE A = 'O' AND B = 1566794 AND C = '011825'", r.Sql);
Assert.AreEqual(3, r.Hits.Count);
}
[TestMethod]
public void _않은_토큰은_손대지_않는다()
{
var r = Run("SELECT 1 FROM DUAL WHERE X = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
StringAssert.Contains(r.Sql, "<<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum");
Assert.AreEqual(0, r.Hits.Count);
}
[TestMethod]
public void _없으면_원문_그대로다()
{
const string sql = "SELECT * FROM P_PatInf WHERE ROWNUM <= 10";
Assert.AreEqual(sql, Run(sql).Sql);
Assert.AreEqual(0, Run(sql).Hits.Count);
}
[TestMethod]
public void _섞인_값을_드러낸다()
{
// 레거시는 값을 원문 그대로 박는다 — 이름에 따옴표가 있으면 SQL 이 깨지거나 문장이 바뀐다.
// 막지 않는다(같은 SQL 을 만들어야 미리보기가 거짓말을 안 한다). 대신 보이게 한다.
var source = new QuoteSource();
var r = QuerySubstitution.Apply(
"WHERE Nam = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'", source);
Assert.AreEqual(1, r.QuotedValues.Count, $"드러나지 않았다: {r.Sql}");
}
private sealed class QuoteSource : IQueryVariableSource
{
public string? Scalar(string className, string property) => "O'Brien";
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
}
#endregion
}
@@ -0,0 +1,208 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 주민번호 보정 고정.
///
/// 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다. 그리고 그건 그럴듯해 보여서
/// 아무도 의심하지 않는다 — 빈칸으로 나오는 고장보다 위험하다.
/// </summary>
[TestClass]
public sealed class ResidentNumberTests
{
#region Methods
[TestMethod]
public void _병원은_앞_여섯자리를_생년월일로_바꾼다()
{
// 레거시 예시: 9750012203265 (앞자리가 날짜가 아니라 보장기관기호)
// 1998-03-04 생 → 앞 6자리가 980304 로 갈린다. 뒤 7자리는 그대로.
Assert.AreEqual("9803042203265",
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "SRCH"));
Assert.AreEqual("9803042203265",
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "JINJU"));
}
[TestMethod]
public void _유효한_날짜여도_바꾼다()
{
// 원본의 If Not DateValidCheck(Substring(1,6)) 는 <b>항상 참</b>이다 —
// DateValidCheck 는 길이가 8·12·14·17 이 아니면 무조건 False 이고 6자리를 넘긴다.
// 즉 조건이 아무것도 걸러 내지 않는다. "고쳐서" 진짜 날짜 검사를 넣으면
// 여기서 레거시와 다른 번호가 나온다. 그 사실을 이 판정으로 못 박는다.
Assert.AreEqual("8801011234567",
ResidentNumber.Normalize("9001011234567", "19880101", string.Empty, "SRCH"),
"앞자리 900101 은 유효한 날짜지만 레거시는 그래도 갈아 끼운다");
}
[TestMethod]
public void _병원은_기관기호_보정을_하지_않는다()
{
Assert.AreEqual("9750012203265",
ResidentNumber.Normalize("9750012203265", "19980304", string.Empty, "HANKOOK"));
}
[TestMethod]
public void _없으면_기관기호_보정을_건너뛴다()
{
// 원본도 PatBthDay 가 비면 갈아 끼우지 않는다 — 바꿀 재료가 없다
Assert.AreEqual("9750012203265",
ResidentNumber.Normalize("9750012203265", string.Empty, string.Empty, "SRCH"));
}
[TestMethod]
public void _일곱번째_자리를_세기로_바꾼다()
{
// 0 → 19xx 면 6, 그 밖이면 8
Assert.AreEqual("8801016234567",
ResidentNumber.Normalize("8801010234567", "19880101", "USA", "HANKOOK"));
Assert.AreEqual("0501018234567",
ResidentNumber.Normalize("0501010234567", "20050101", "USA", "HANKOOK"));
// 9 → 19xx 면 5, 그 밖이면 7
Assert.AreEqual("8801015234567",
ResidentNumber.Normalize("8801019234567", "19880101", "USA", "HANKOOK"));
Assert.AreEqual("0501017234567",
ResidentNumber.Normalize("0501019234567", "20050101", "USA", "HANKOOK"));
}
[TestMethod]
public void _없으면_외국인_보정을_하지_않는다()
{
// 이 조건이 빠지면 내국인의 7번째 자리까지 바꿔 버린다
Assert.AreEqual("8801010234567",
ResidentNumber.Normalize("8801010234567", "19880101", string.Empty, "HANKOOK"));
}
[TestMethod]
public void _자리가_0_9_가_아니면_손대지_않는다()
{
foreach (var digit in new[] { '1', '2', '3', '4', '5', '6', '7', '8' })
{
var input = "880101" + digit + "234567";
Assert.AreEqual(input,
ResidentNumber.Normalize(input, "19880101", "USA", "HANKOOK"),
$"7번째 자리 {digit} 는 보정 대상이 아니다");
}
}
[TestMethod]
public void _보정이_함께_걸린다()
{
// SRCH 에서 앞자리를 갈고, 그 결과의 7번째 자리가 0 이면 외국인 보정까지 이어진다.
// 순서가 뒤바뀌면 다른 값이 나온다 — 기관기호 보정이 7번째 자리를 새로 정하기 때문이다.
Assert.AreEqual("9803046203265",
ResidentNumber.Normalize("9750010203265", "19980304", "USA", "SRCH"));
}
[TestMethod]
public void _안_되면_그대로_돌려준다()
{
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
Assert.AreEqual("880101", ResidentNumber.Normalize("880101", "19880101", "USA", "SRCH"));
Assert.AreEqual(string.Empty, ResidentNumber.Normalize(string.Empty, "19880101", "USA", "SRCH"));
Assert.AreEqual(string.Empty, ResidentNumber.Normalize(null, null, null, null));
}
[TestMethod]
public void _일곱번째_자리_홀짝이다()
{
Assert.AreEqual("M", ResidentNumber.SexOf("8801011234567"));
Assert.AreEqual("F", ResidentNumber.SexOf("8801012234567"));
Assert.AreEqual("M", ResidentNumber.SexOf("0501013234567"));
Assert.AreEqual("F", ResidentNumber.SexOf("0501014234567"));
// 외국인 자리(5~8)도 같은 규칙이다
Assert.AreEqual("M", ResidentNumber.SexOf("8801015234567"));
Assert.AreEqual("F", ResidentNumber.SexOf("8801016234567"));
}
[TestMethod]
public void _만들_수_없으면_빈_값이다()
{
// 원본은 CLong 에서 예외가 나고 삼켜져 값이 빈다 — 추측해서 채우지 않는다
Assert.AreEqual(string.Empty, ResidentNumber.SexOf("880101"));
Assert.AreEqual(string.Empty, ResidentNumber.SexOf("880101X234567"));
Assert.AreEqual(string.Empty, ResidentNumber.SexOf(null));
}
[TestMethod]
public void _성별은_남녀로_바꾼다()
{
Assert.AreEqual("남", ResidentNumber.SexKoreanOf("8801011234567"));
Assert.AreEqual("여", ResidentNumber.SexKoreanOf("8801012234567"));
Assert.AreEqual(string.Empty, ResidentNumber.SexKoreanOf("880101"));
}
[TestMethod]
public void _월_경계만_센다()
{
// VB DateDiff(Month, ...) 는 날짜를 보지 않는다 — 연차×12 + 월차, 거기서 1을 뺀다.
// 일수로 환산하면 레거시와 다른 수가 나온다.
// 2025-01 → 2025-12 는 월 경계 11, 거기서 1을 뺀 10 이다(기대값을 처음에 11로 잘못 적었다)
Assert.AreEqual("10", ResidentNumber.MonthsSince("20250101", "20251201"));
// 같은 달이면 -1 이 된다(레거시 그대로 — 음수도 그대로 찍힌다)
Assert.AreEqual("-1", ResidentNumber.MonthsSince("20260101", "20260101"));
// 날짜는 보지 않는다: 1일생과 말일생이 같은 값
Assert.AreEqual(ResidentNumber.MonthsSince("20250101", "20260101"),
ResidentNumber.MonthsSince("20250131", "20260101"));
Assert.AreEqual(string.Empty, ResidentNumber.MonthsSince("2025", "20260101"));
}
[TestMethod]
public void _일은_달력이_아니라_30일로_나눈다()
{
// 레거시가 days \ 30 · days Mod 30 이다. 달력으로 고치면 값이 달라진다.
Assert.AreEqual("0개월 10일", ResidentNumber.MonthsDaysSince("20260101", "20260111"));
Assert.AreEqual("1개월 1일", ResidentNumber.MonthsDaysSince("20260101", "20260201"));
Assert.AreEqual("12개월 5일", ResidentNumber.MonthsDaysSince("20250101", "20260101"));
Assert.AreEqual(string.Empty, ResidentNumber.MonthsDaysSince("2026", "20260101"));
}
[TestMethod]
public void _양쪽_공백을_지킨다()
{
// 레거시가 " - " 다. 공백을 빼면 서식에서 폭이 달라진다
Assert.AreEqual("880101 - 1234567", ResidentNumber.WithDash("8801011234567"));
Assert.AreEqual("880101", ResidentNumber.WithDash("880101"));
}
[TestMethod]
public void _세기_접두를_7번째_자리로_정한다()
{
// 1·2 → 19xx, 3·4 → 20xx (AgeCheck, clsCommonLib.vb:686-700)
Assert.AreEqual(38L, ResidentNumber.AgeOf("8801011234567", "20260820", "20260820"));
Assert.AreEqual(6L, ResidentNumber.AgeOf("2001013234567", "20260820", "20260820"));
// 7·8 — 서버 오늘의 연도 2자리(26)보다 크면 19xx: 88 > 26 → 1988년생
Assert.AreEqual(38L, ResidentNumber.AgeOf("8801017234567", "20260820", "20260820"));
// 그 외(9) → 18xx
Assert.AreEqual(138L, ResidentNumber.AgeOf("8801019234567", "20260820", "20260820"));
}
[TestMethod]
public void _생일_전이면_하나_줄고_당일은_줄지_않는다()
{
Assert.AreEqual(37L, ResidentNumber.AgeOf("8812311234567", "20260820", "20260820"));
Assert.AreEqual(38L, ResidentNumber.AgeOf("8808201234567", "20260820", "20260820"));
}
[TestMethod]
public void _살_이하는_고정_365일로_가른다()
{
// 총 일수차 < 365 → 0세, 이상 → 1세 (윤년 무시 — 레거시 고정 365)
Assert.AreEqual(0L, ResidentNumber.AgeOf("2510013234567", "20260820", "20260820"));
Assert.AreEqual(1L, ResidentNumber.AgeOf("2501013234567", "20260820", "20260820"));
Assert.AreEqual(0L, ResidentNumber.AgeOf("", "20260820", "20260820"));
}
[TestMethod]
public void _5_6은_출생_2자리로_갈린다()
{
// 출생 2자리 < 20 → 20xx 그대로, >= 20 → 19xx + 월일 0101 강제.
// 경계 주의: "20" 은 < 20 이 아니라서 2020년생이 아니라 1920-01-01 로 강제된다(레거시 그대로)
Assert.AreEqual(20L, ResidentNumber.AgeOf("0601015234567", "20260820", "20260820"));
Assert.AreEqual(106L, ResidentNumber.AgeOf("2001015234567", "20260820", "20260820"));
// 88 >= 20 → 1988-01-01 로 강제 → 만 38세
Assert.AreEqual(38L, ResidentNumber.AgeOf("8812315234567", "20260820", "20260820"));
}
#endregion
}
@@ -0,0 +1,123 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// 싸인 이미지 경로 판정 — 파일서버 전송(ActiveX)을 옮기지 않았으므로
/// "받지 않고 찾는" 규칙이 값의 유무를 가른다. DB·파일 없이 검증한다.
/// </summary>
[TestClass]
public sealed class SignatureImagePathTests
{
#region Methods
private const string Install = @"C:\MsystechHIS_Ver.2";
private const string Remote = @"C:\Sign\Server\hong.tif";
private static readonly string Cached = @"C:\MsystechHIS_Ver.2\Tmp\DownLoadedFiles\Signature\hong.tif";
[TestMethod]
public void _받아둔_캐시가_있으면_그것을_쓴다()
{
var result = SignatureImagePath.Resolve(Remote, Install, p => p == Cached);
Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin);
Assert.AreEqual(Cached, result.LocalPath);
Assert.AreEqual("hong.tif", result.FileName);
}
[TestMethod]
public void _없으면_DB_경로_자체를_본다()
{
var result = SignatureImagePath.Resolve(Remote, Install, p => p == Remote);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
Assert.AreEqual(Remote, result.LocalPath);
}
[TestMethod]
public void _원격보다_먼저다()
{
// 둘 다 있으면 캐시 — 레거시가 그 폴더에 받아 두고 그 경로를 돌려준다
var result = SignatureImagePath.Resolve(Remote, Install, _ => true);
Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin);
}
[TestMethod]
public void _다_없으면_없음이고_파일명은_남는다()
{
// 파일명은 사유 문구에 쓴다 — "무엇을 못 찾았는지" 를 말할 수 있어야 한다
var result = SignatureImagePath.Resolve(Remote, Install, _ => false);
Assert.AreEqual(SignatureImageOrigin.None, result.Origin);
Assert.AreEqual(string.Empty, result.LocalPath);
Assert.AreEqual("hong.tif", result.FileName);
}
[TestMethod]
public void _비면_아무것도_찾지_않는다()
{
var result = SignatureImagePath.Resolve(" ", Install, _ => true);
Assert.AreEqual(SignatureImageOrigin.None, result.Origin);
Assert.AreEqual(string.Empty, result.FileName);
}
[TestMethod]
public void _없으면_캐시를_보지_않는다()
{
// 레거시는 설치 경로가 없으면 "\Tmp\..." 라는 루트 상대경로를 만든다 — 그 결함은 안 옮긴다
Assert.AreEqual(string.Empty, SignatureImagePath.CacheFolder(null));
var result = SignatureImagePath.Resolve(Remote, null, p => p == Remote);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
}
[TestMethod]
public void _매핑이_서버경로를_UNC_로_바꾼다()
{
var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
SignatureImagePath.MapToShare(@"D:\MsystechHIS\002\a.tif", map));
// 대소문자 무시 — 병원 데이터의 표기가 제각각이다
Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
SignatureImagePath.MapToShare(@"d:\msystechhis\002\a.tif", map));
// 대응 없는 접두는 빈 문자열(엉뚱한 경로를 만들지 않는다)
Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"E:\other\a.tif", map));
}
[TestMethod]
public void _겹치면_긴_쪽이_이긴다()
{
// 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다
var map = new[] { @"D:\HIS=\\fs\all", @"D:\HIS\Sign=\\fs\sign" };
Assert.AreEqual(@"\\fs\sign\a.tif", SignatureImagePath.MapToShare(@"D:\HIS\Sign\a.tif", map));
Assert.AreEqual(@"\\fs\all\Etc\b.tif", SignatureImagePath.MapToShare(@"D:\HIS\Etc\b.tif", map));
}
[TestMethod]
public void _오타는_값으로_바꾸지_않는다()
{
// '=' 가 없거나 한쪽이 비면 그 항목은 무시한다 — 반쪽 경로를 만들면 조용히 틀린다
var map = new[] { "그냥문자열", @"D:\HIS=", @"=\\fs\x" };
Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"D:\HIS\a.tif", map));
}
[TestMethod]
public void _없으면_공유를_먼저_보고_직접경로는_그_다음이다()
{
var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
var remote = @"D:\MsystechHIS\002\a.tif";
var shared = @"\\fs\MsystechHIS\002\a.tif";
// 공유에 있으면 공유
var viaShare = SignatureImagePath.Resolve(remote, Install, p => p == shared, map);
Assert.AreEqual(SignatureImageOrigin.Share, viaShare.Origin);
Assert.AreEqual(shared, viaShare.LocalPath);
// 공유에 없고 원격 경로가 열리면 직접
var viaDirect = SignatureImagePath.Resolve(remote, Install, p => p == remote, map);
Assert.AreEqual(SignatureImageOrigin.DirectPath, viaDirect.Origin);
}
[TestMethod]
public void UNC_공유_경로도_파일명을_뗀다()
{
var unc = @"\\fileserver\sign\kim.jpg";
var result = SignatureImagePath.Resolve(unc, Install, p => p == unc);
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
Assert.AreEqual("kim.jpg", result.FileName);
}
#endregion
}
@@ -48,10 +48,13 @@ public sealed class TagPreviewCatalogTests
} }
[TestMethod] [TestMethod]
public void _경로_태그는_따로_가른() public void _경로_태그도_환자_문맥이()
{ {
Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인")); // 2026-08-20: 싸인·직인·로고를 이식하면서 분류를 옮겼다.
Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ETC_병원직인")); // 값은 '이미지 파일 경로'이고 환자 해석기가 그것을 얻어 PictureBox 가 그린다 —
// Image 로 두면 "글자만 보여 준다"는 틀린 사유가 화면에 나간다.
Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인"));
Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ETC_병원직인"));
} }
[TestMethod] [TestMethod]
@@ -105,7 +108,9 @@ public sealed class TagPreviewCatalogTests
Assert.AreEqual("2026-08-1414:32", TagPreviewCatalog.FromClock("ETC_현재일시_MaskBox", stamp)); Assert.AreEqual("2026-08-1414:32", TagPreviewCatalog.FromClock("ETC_현재일시_MaskBox", stamp));
// Data2Format_ENG 는 MM-DD-YYYY 로 재배치한다 // Data2Format_ENG 는 MM-DD-YYYY 로 재배치한다
Assert.AreEqual("08-14-2026", TagPreviewCatalog.FromClock("ETC_현재일자_영문", stamp)); Assert.AreEqual("08-14-2026", TagPreviewCatalog.FromClock("ETC_현재일자_영문", stamp));
Assert.AreEqual("2026", TagPreviewCatalog.FromClock("ETC_현재년도", stamp)); // 접미 "년"이 붙는다(bzDataInterface.vb:16923) — 형제 ETC_현재일시_1_년 은 안 붙는다
Assert.AreEqual("2026년", TagPreviewCatalog.FromClock("ETC_현재년도", stamp));
Assert.AreEqual("2026", TagPreviewCatalog.FromClock("ETC_현재일시_1_년", stamp));
Assert.AreEqual("08", TagPreviewCatalog.FromClock("ETC_현재일시_2_월", stamp)); Assert.AreEqual("08", TagPreviewCatalog.FromClock("ETC_현재일시_2_월", stamp));
Assert.AreEqual("14:32", TagPreviewCatalog.FromClock("ETC_현재시간", stamp)); Assert.AreEqual("14:32", TagPreviewCatalog.FromClock("ETC_현재시간", stamp));
} }
@@ -125,8 +130,9 @@ public sealed class TagPreviewCatalogTests
Assert.AreEqual("U01.홍길동", Assert.AreEqual("U01.홍길동",
TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", "홍길동", "010", "1234")); TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", "홍길동", "010", "1234"));
Assert.AreEqual("010", TagPreviewCatalog.FromUser("ETC_로그인_사용자연락처", "U01", "홍길동", "010", "1234")); Assert.AreEqual("010", TagPreviewCatalog.FromUser("ETC_로그인_사용자연락처", "U01", "홍길동", "010", "1234"));
// 한쪽이 비면 점만 남은 값을 만들지 않는다 // 레거시(bzDataInterface.vb:17930)는 빈 값 검사 없이 <b>항상</b> 결합한다 —
Assert.AreEqual(string.Empty, // 한쪽이 비어도 "U01." 이 그대로 나간다. 전에는 빈 값으로 뭉갰는데 그건 레거시와 달랐다.
Assert.AreEqual("U01.",
TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", string.Empty, "010", "1234")); TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", string.Empty, "010", "1234"));
} }
#endregion #endregion