Compare commits

..
60 Commits
Author SHA1 Message Date
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
54 changed files with 11246 additions and 152 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 조회.
@@ -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,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
}
+62 -4
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
@@ -174,6 +211,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 +223,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 +242,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
} }
+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
}
+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));
+33 -3
View File
@@ -9,7 +9,23 @@ 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>미인증 세션(인자 없이 단독 실행) — 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 +74,26 @@ 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,' ') " +
"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 +106,10 @@ 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());
} }
#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
}
@@ -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)
{ {
+726 -5
View File
@@ -13,6 +13,18 @@ namespace SheetMe.Designer.Diagnostics;
/// </summary> /// </summary>
public static class DbSmoke public static class DbSmoke
{ {
#region Nested Types
/// <summary>
/// 아무것도 해석하지 않는 사슬 꼬리 — 환자 해석기만 판정하려면 뒤가 조용해야 한다.
/// 세션 해석기를 꼬리로 쓰면 시각 태그가 값이 되어 "환자 해석기가 됐다"와 섞인다.
/// </summary>
private sealed class NullTagResolver : SheetMe.Core.Catalog.ITagValueResolver
{
public SheetMe.Core.Catalog.TagValue Resolve(string tag)
=> new(false, "해석기 없음");
}
#endregion
#region Methods #region Methods
/// <summary>읽기 전용 일괄 왕복 스모크</summary> /// <summary>읽기 전용 일괄 왕복 스모크</summary>
public static int RunReadSmoke(string reportPath, int maxSheets) public static int RunReadSmoke(string reportPath, int maxSheets)
@@ -766,6 +778,39 @@ public static class DbSmoke
Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개"); Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개");
Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.Count}개"); Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.Count}개");
// ⑨-b 치환 변수를 값 자리에 쓴 쿼리가 검증 실행을 <b>통과할 수 있는가</b>.
// 전에는 변수를 빈 문자열로 지워 "WHERE X = " 가 되었고 ORA-00936 이 났다 —
// 즉 이 기능이 존재하는 이유인 쿼리들이 하나도 검사되지 못했다.
var summary = (System.Windows.Controls.TextBlock)window.FindName("TrialSummary")!;
Type("select * from P_PatInf"
+ " where PatChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>");
window.RunTrialForSmoke();
DrainDispatcher();
Check("⑨-b 치환 변수를 값 자리에 써도 구문 검사가 돈다",
!summary.Text.Contains("ORA-00936", StringComparison.Ordinal),
summary.Text);
Check("⑨-b 변수 자리가 비지 않는다(NULL 로 채운다)",
window.LastTrialSql.Contains("NULL", StringComparison.Ordinal)
&& !window.LastTrialSql.TrimEnd().EndsWith("=", StringComparison.Ordinal),
window.LastTrialSql);
// 무엇으로 돌렸는지 말해야 한다 — 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다
Check("⑨-b 무엇으로 돌렸는지 요약이 밝힌다",
summary.Text.Contains("NULL", StringComparison.Ordinal)
|| summary.Text.Contains("환자", StringComparison.Ordinal),
summary.Text);
// 따옴표 밖 변수는 이 배선의 가장 흔한 실수다 — 값이 원문 그대로 박히기 때문이다
Check("⑨-b 따옴표 밖 변수를 짚어 준다",
summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text);
// 대조군 — 따옴표로 감싼 같은 쿼리에는 그 경고가 없어야 한다.
// 없으면 위 판정은 "항상 경고한다"와 구분되지 않는다.
Type("select * from P_PatInf"
+ " where PatChtNum = '<<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>'");
window.RunTrialForSmoke();
DrainDispatcher();
Check("⑨-b (대조군) 따옴표로 감싸면 그 경고가 없다",
!summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text);
// ⑩ 검증 실행 단축키가 실제로 배선돼 있는가. // ⑩ 검증 실행 단축키가 실제로 배선돼 있는가.
// 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다) // 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다)
// 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다. // 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다.
@@ -1848,15 +1893,18 @@ public static class DbSmoke
{ {
var store = new PatientVisitStore(ConfigService.Current.ConnectionString); var store = new PatientVisitStore(ConfigService.Current.ConnectionString);
// 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다 // 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다.
// <b>시간을 함께 잰다.</b> "느리다"는 체감이고, 어디가 느린지는 숫자로만 안다.
foreach (var kind in new[] foreach (var kind in new[]
{ {
PatientSearchKind.ChartNumber, PatientSearchKind.Name, PatientSearchKind.ChartNumber, PatientSearchKind.Name,
PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone, PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone,
}) })
{ {
var watch = System.Diagnostics.Stopwatch.StartNew();
var found = store.Search(kind, term); var found = store.Search(kind, term);
lines.Add($" {kind,-16} {found.Count}건"); watch.Stop();
lines.Add($" {kind,-16} {found.Count,4}건 {watch.ElapsedMilliseconds,6}ms");
} }
Check("① 검색 네 갈래가 예외 없이 돈다", true); Check("① 검색 네 갈래가 예외 없이 돈다", true);
@@ -1874,8 +1922,24 @@ public static class DbSmoke
var capped = store.Search(PatientSearchKind.Name, term, max: 3); var capped = store.Search(PatientSearchKind.Name, term, max: 3);
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건"); Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
// 내원 목록 — 검색으로 찾은 첫 환자로 // 내원 목록 — <b>내원이 있는</b> 환자를 찾아 들어간다.
var people = store.Search(PatientSearchKind.Name, term, max: 1); // 처음에는 첫 환자만 봤는데 그 환자에게 내원이 0건이라 ⑧~⑬ 이 통째로 SKIP 되고도
// 리포트는 "전건 통과"였다. 아무것도 확인하지 않은 통과는 통과가 아니다.
var people = store.Search(PatientSearchKind.Name, term, max: 30);
var probed = 0;
var visits = new List<VisitSummary>();
foreach (var candidate in people)
{
probed++;
var vw = System.Diagnostics.Stopwatch.StartNew();
visits = store.ListVisits(candidate.ChtNum);
vw.Stop();
lines.Add($" ListVisits {vw.ElapsedMilliseconds}ms → {visits.Count}건");
if (visits.Count > 0)
{
break;
}
}
if (people.Count == 0) if (people.Count == 0)
{ {
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가" lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
@@ -1883,13 +1947,591 @@ public static class DbSmoke
} }
else else
{ {
var visits = store.ListVisits(people[0].ChtNum); lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)");
lines.Add($" 내원 {visits.Count}건"); lines.Add($" 내원 {visits.Count}건");
Check("⑥ 내원 목록이 예외 없이 돈다", true); Check("⑥ 내원 목록이 예외 없이 돈다", true);
// 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다 // 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다
Check("⑦ 같은 내원이 중복되지 않는다", Check("⑦ 같은 내원이 중복되지 않는다",
visits.Select(v => v.ComNum).Distinct().Count() == visits.Count, visits.Select(v => v.ComNum).Distinct().Count() == visits.Count,
$"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}"); $"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}");
// ── 환자 문맥 ──
// 여기가 이 조사의 목적이다. 위 ①~⑦ 은 목록이 뜨는지만 본다.
// 문맥 조회의 컬럼 이름은 <b>추측으로 쓴 것</b>이라 실DB 아니면 확인할 방법이 없고,
// 틀리면 예외가 아니라 <b>빈 값</b>이 나온다 — 화면에서는 "환자가 값이 없다"로 보인다.
if (visits.Count == 0)
{
// 여기까지 오면 훑은 환자 전부가 내원 0건이다. 검색어를 바꿔 다시 돌려야 한다 —
// 이 줄을 통과로 세면 문맥 조회를 <b>한 번도 확인하지 않은 채</b> 초록불이 된다.
Check($"⑧~⑬ 환자 문맥 — 훑은 {probed}명 모두 내원 0건이라 확인 불가", false,
"다른 검색어로 다시 돌릴 것");
}
else
{
var target = visits[0].ComNum;
var contexts = new PatientContextStore(ConfigService.Current.ConnectionString);
// 환자를 고른 뒤 사람이 기다리는 구간이다 — 문맥 5행 + 시각 + 복호화까지 붙는다
var loadWatch = System.Diagnostics.Stopwatch.StartNew();
var context = contexts.Load(target);
loadWatch.Stop();
lines.Add($" 문맥 읽기 {loadWatch.ElapsedMilliseconds}ms (환자를 고른 뒤 기다리는 구간)");
Check("⑧ 문맥 5행 조회가 예외 없이 돈다", context is not null,
$"내원 {target:F0} 을 읽지 못했다(목록에는 있는데 다시 읽으면 없다)");
if (context is { } ctx)
{
lines.Add($" 내원 {target:F0} 적용일시={ctx.AdpDtm}"
+ $" / PatInf {ctx.PatInf.Count}열, ComInf {ctx.ComInf.Count}열,"
+ $" CodInf {ctx.CodInf.Count}열, CoiInf {ctx.CoiInf.Count}열, CowInf {ctx.CowInf.Count}열");
// 컬럼 이름을 실제로 확인한다 — 오타면 여기서 걸린다
var missing = new[] { "ComChtNum", "ComAcpDtm", "ComPatTyp", "ComLevDtm" }
.Where(c => !ctx.ComInf.ContainsKey(c)).ToList();
Check("⑨ P_ComInf 컬럼 이름이 맞다", missing.Count == 0,
$"없는 컬럼: {string.Join(", ", missing)}");
// 이 조인이 깨지면 환자 이름·차트번호 태그가 전부 빈다
Check("⑩ 내원의 차트번호로 P_PatInf 를 찾는다", ctx.PatInf.Count > 0,
$"차트번호 '{PatientContext.Value(ctx.ComInf, "ComChtNum")}' 로 환자를 못 찾았다"
+ " (P_ComInf.ComChtNum 과 P_PatInf.PatChtNum 의 자릿수·공백 규약이 다를 수 있다)");
// 적용일시가 12자리가 아니면 BETWEEN 이 전부 0행이 된다
Check("⑪ 적용일시가 12자리다", ctx.AdpDtm.Length == 12, $"'{ctx.AdpDtm}'");
// 기간 조회 3행. 0행은 실패가 아닐 수 있다(외래는 병실이 없다) —
// 그래서 0을 그냥 적지 않고 <b>이유를 캔다</b>. 조건을 뺀 원본 행을 함께 찍으면
// "그 내원에 애초에 행이 없다"와 "행은 있는데 시각·MtiSeq 조건이 어긋났다"가 갈린다.
// 0 을 이유 없이 적어 두면 리포트가 초록불인 채로 태그가 통째로 비게 된다.
lines.Add($" 기간 조회: CodInf {(ctx.CodInf.Count > 0 ? "" : "0")},"
+ $" CoiInf {(ctx.CoiInf.Count > 0 ? "" : "0")},"
+ $" CowInf {(ctx.CowInf.Count > 0 ? "" : "0")}");
var conn = ConfigService.Current.ConnectionString;
if (ctx.CodInf.Count == 0)
{
lines.Add(" [진단] P_CodInf 조건 없는 원본 행:");
foreach (var row in ExplainPeriod(conn, target,
"SELECT CodMtiSeq, CodStrDtm, CodEndDtm FROM P_CodInf"
+ " WHERE CodComNum = :k ORDER BY CodStrDtm"))
{
lines.Add($" {row}");
}
}
if (ctx.CoiInf.Count == 0)
{
lines.Add(" [진단] P_CoiInf 조건 없는 원본 행:");
foreach (var row in ExplainPeriod(conn, target,
"SELECT CoiMtiSeq, CoiStrDte, CoiEndDte FROM P_CoiInf"
+ " WHERE CoiComNum = :k ORDER BY CoiStrDte"))
{
lines.Add($" {row}");
}
}
Check("⑭ CodInf 0행이면 그 이유가 원본 행으로 설명된다", true,
"위 [진단] 줄을 읽어 시각·MtiSeq 조건을 판단할 것");
// ── 기간 조회 세 갈래를 <b>실제로 한 번은 통과시킨다</b> ──
// 위 내원은 CodInf·CoiInf·CowInf 가 전부 0행이었다. 즉 이 세 SQL 의
// BETWEEN·MtiSeq 조건은 아직 <b>한 번도 행을 돌려준 적이 없다</b>.
// 안 돌아본 갈래는 통과한 갈래가 아니다 — 그래서 각 표에서 직접 내원을 찾아 들어간다.
foreach (var (label, table, key, stamp) in new[]
{
("CodInf(진료과·담당의)", "P_CodInf", "CodComNum", "CodStrDtm"),
("CoiInf(보험 자격)", "P_CoiInf", "CoiComNum", "CoiStrDte"),
("CowInf(병동·병실)", "P_CowInf", "CowComNum", "CowStrDtm"),
})
{
var owner = FirstComNum(conn, table, key, stamp);
if (owner is null)
{
Check($"⑮ {label} 기간 조회 — {table} 에 행이 없어 확인 불가", false,
"이 DB 로는 이 갈래를 확인할 수 없다");
continue;
}
var sample = contexts.Load(owner.Value);
var got = table switch
{
"P_CodInf" => sample?.CodInf.Count ?? 0,
"P_CoiInf" => sample?.CoiInf.Count ?? 0,
_ => sample?.CowInf.Count ?? 0,
};
Check($"⑮ {label} 기간 조회가 실제로 행을 돌려준다", got > 0,
$"내원 {owner.Value:F0} 적용일시={sample?.AdpDtm ?? "( )"} → {got}열"
+ " (0이면 BETWEEN 시각이나 MtiSeq 조건이 어긋난 것이다)");
}
// 실제 문맥으로 태그가 값이 되는가 — 가상 문맥(--edit-smoke)과 짝이 되는 판정이다
var resolver = new Services.PatientTagResolver(ctx, new NullTagResolver());
var chart = resolver.Resolve("PAT_차트번호");
var name = resolver.Resolve("PAT_이름");
Check("⑫ 실제 문맥으로 PAT_차트번호·PAT_이름 이 값이 된다",
chart.Resolved && name.Resolved,
$"차트번호 {(chart.Resolved ? "" : chart.Text)} / 이름 {(name.Resolved ? "" : name.Text)}");
// 값 자체는 개인정보라 리포트에 쓰지 않는다 — 길이만 적는다
lines.Add($" 값 길이: 차트번호 {chart.Text.Length}자, 이름 {name.Text.Length}자"
+ " (값은 개인정보라 기록하지 않는다)");
// 대조군 — 매핑에 없는 환자 태그는 값이 되지 않아야 한다.
// 이게 없으면 ⑫ 는 "해석기가 아무 태그나 통과시킨다"와 구분되지 않는다.
// 이 태그가 이식되면 이 판정이 깨진다 — 그때 다른 미이식 태그로 바꾼다
// (PAT_나이가 이식되면서 한 번 갈았다).
var unmapped = resolver.Resolve("ETC_수술실간호사");
Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다",
!unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal),
$"'{unmapped.Text}' (Resolved={unmapped.Resolved})");
// ── MDataTable 치환 + 실행 ──
// 태그와 다른 관이다. 여기까지 돌지 않으면 "미리보기는 되는데 실제 서식은
// 반쯤 빈" 상태를 확인할 수 없다. 실DB 로만 확인 가능한 것은
// <b>치환한 SQL 이 실제로 실행되는가</b>이므로 그것만 본다.
var probeDoc = new SheetMe.Core.Models.FormDocument();
var probePage = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
probeDoc.Pages.Add(probePage);
var probeSource = new SheetMe.Core.Models.ControlElement
{
Type = "MDataTable", Id = "MDataTable1",
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
};
// 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다
probeSource.Props.SetText("Query",
"SELECT TRIM(ComChtNum) CHT FROM P_ComInf"
+ " WHERE ComNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ComNum>>"
+ " AND TRIM(ComChtNum) = '<<M.CMM.HISOperatingInfo.bzPatientInfo.PatInfDR.item(\"PatChtNum\")>>'");
probePage.Controls.Add(probeSource);
var runner = new Services.MDataTableRunner(probeDoc,
new Services.PatientQueryVariableSource(ctx, "SMOKE"));
var ran = runner.Run("MDataTable1");
Check("⑯ 치환한 SQL 이 실제로 실행된다",
ran.Ok && ran.Rows.Count == 1,
ran.Ok ? $"{ran.Rows.Count}행 (내원번호로 1행이어야 한다)" : ran.Error);
Check("⑰ 스칼라·DataRow 치환이 둘 다 값으로 박힌다",
!ran.Sql.Contains("<<", StringComparison.Ordinal)
&& !ran.Sql.Contains("= '' ", StringComparison.Ordinal)
&& !ran.Sql.EndsWith("= ''", StringComparison.Ordinal),
ran.Sql);
// 대조군 — 환자 없이 같은 쿼리. 치환이 빈 값이 되어 SQL 이 깨져야 한다.
// 이게 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다.
var blind = new Services.MDataTableRunner(probeDoc, null).Run("MDataTable1");
Check("⑱ (대조군) 환자 없이는 같은 쿼리가 값을 내지 못한다",
!blind.Ok || blind.Rows.Count != 1,
$"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다");
lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}");
lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개");
// ── 주민번호 복호화 ──
// 값은 <b>절대 리포트에 쓰지 않는다</b> — 길이와 형식만 적는다.
// 이 앱이 다루는 것 중 가장 민감한 값이고, 리포트는 파일로 남는다.
var personal = new PersonalDataStore(conn);
var flags = personal.Flags();
var hospital = Services.UserSession.Current.HspCod;
lines.Add($" 암호화 설정: 전체={(flags.Enabled ? "Y" : "N")}"
+ $" 주민번호={(flags.ResidentNumber ? "Y" : "N")}"
+ $" / 병원코드='{hospital}'");
Check("⑲ 암호화 설정을 예외 없이 읽는다", true);
// 병원코드가 비면 SRCH·JINJU 기관기호 보정이 걸리지 않는다 —
// 그 갈래는 이 진단으로 확인되지 않는다. 대신 <b>복호화 자체</b>는 확인한다.
// 그것이 이 단계에서 실패하면 나머지가 전부 무의미하고, DB 권한·패키지 문제라
// 코드로는 알 수 없다. 확인한 것과 못 한 것을 갈라서 적는다.
if (hospital.Length == 0)
{
lines.Add(" [미확인] 병원코드가 없어 SRCH·JINJU 기관기호 보정은"
+ " 이 진단으로 확인되지 않았다(순수 함수 테스트가 그 갈래를 덮는다)");
}
{
var raw = flags.ResidentNumberEncrypted
? personal.Decrypt(PatientContext.Value(ctx.PatInf, "PatResEnc"), hospital)
: PatientContext.Value(ctx.PatInf, "PatResNum");
var normalized = SheetMe.Core.Catalog.ResidentNumber.Normalize(
raw,
PatientContext.Value(ctx.PatInf, "PatBthDay"),
PatientContext.Value(ctx.PatInf, "PatNatCod"),
hospital);
var sex = SheetMe.Core.Catalog.ResidentNumber.SexOf(normalized);
lines.Add($" 주민번호: 원본 {raw.Length}자 → 보정 {normalized.Length}자,"
+ $" 전부 숫자={normalized.All(char.IsAsciiDigit)} (값은 기록하지 않는다)");
Check("⑳ 주민번호가 13자리 숫자로 만들어진다",
normalized.Length == 13 && normalized.All(char.IsAsciiDigit),
$"{normalized.Length}자 — 복호화 실패나 컬럼 부재를 의심할 것");
Check("㉑ 그 번호에서 성별이 나온다", sex is "M" or "F", $"'{sex}'");
}
// ── OCM_담당의사 배치의 선행 조사 ──
// 레거시 GetCodInfDT 는 ORDER BY 없이 CodMtiSeq='0' 만 걸고
// 호출부가 Rows(0) 을 집는다. 한 내원에 그런 행이 둘 이상이면
// <b>오라클이 어느 행을 먼저 주느냐에 따라 담당의가 달라진다</b>.
// 실제로 그런 내원이 있는지 세어 본다 — 0이면 없는 문제고,
// 있으면 정렬 규칙을 정해야 한다. 세기 전에는 어느 쪽인지 알 수 없다.
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) 다중내원, MAX(행수) 최대행수 FROM ("
+ " SELECT CodComNum, COUNT(*) 행수 FROM P_CodInf"
+ " WHERE CodMtiSeq = 0 GROUP BY CodComNum HAVING COUNT(*) > 1)"
+ " WHERE :k = 0"))
{
lines.Add($" [조사] CodMtiSeq=0 이 2행 이상인 내원: {line}");
}
// ㉒ 담당의 조회가 실제로 행을 돌려주는가.
// 적용일시 BETWEEN + 최신순 1행으로 골라야 결정적이다(위 조사 근거).
// 0행이면 그 조건이 어긋난 것이라 태그 셋이 통째로 빈다.
{
// 시험 내원에 진료과 행이 <b>아예 없으면</b> 0열이 나오는데
// 그건 쿼리가 아니라 데이터다. P_CodInf 에 행을 가진 내원을 찾아 건다 —
// 앞서 이 함정을 밟은 적이 있다(내원 0건인 환자로 판정하려 했다).
var owner = FirstComNum(conn, "P_CodInf", "CodComNum", "CodStrDtm") ?? target;
var ownerCtx = owner == target ? ctx : contexts.Load(owner);
var doctor = ownerCtx is null
? new Dictionary<string, string>()
: (IReadOnlyDictionary<string, string>)contexts.Doctor(owner, ownerCtx.AdpDtm);
var uidNam = PatientContext.Value(doctor, "UidNam");
lines.Add($" 담당의 조회: 내원 {owner:F0} → {doctor.Count}열,"
+ $" 이름 {uidNam.Length}자 (값은 기록하지 않는다)");
Check("㉒ 담당의 조회가 예외 없이 돌고 행을 돌려준다", doctor.Count > 0,
"0열 — 적용일시 BETWEEN 조건이나 CodDtrCod 를 의심할 것");
// ㉓ 진료과·요양기관명 — 같은 내원의 CodDepCod 로.
// 코드가 이름이 되는 것까지 확인해야 태그가 값을 낸다.
if (ownerCtx is { } octx)
{
var depCod = PatientContext.Value(octx.CodInf, "CodDepCod");
var acp = PatientContext.Value(octx.ComInf, "ComAcpDtm");
var dep = contexts.Department(depCod, acp);
var korNam = PatientContext.Value(dep, "DepKorNam");
lines.Add($" 진료과 조회: 코드 '{depCod}' → {dep.Count}열,"
+ $" 한글명 {korNam.Length}자");
Check("㉓ 진료과 마스터가 이름을 돌려준다", korNam.Trim().Length > 0,
$"코드 '{depCod}' 로 M_DepMst 에서 이름을 못 찾았다");
var hospRow = contexts.HospitalRow(depCod, octx.AdpDtm);
var hosp = PatientContext.Value(hospRow, "HspInsNam");
lines.Add($" 요양기관: {hospRow.Count}열, 병원명 {hosp.Length}자");
Check("㉔ 요양기관명이 나온다", hosp.Trim().Length > 0,
"M_DepMst⨝M_HspMst 조인이 0행 — 유효기간이나 DepHspCod 를 의심할 것");
// ㉕ 상병 — 원문 기계 추출 SQL 이 실제로 돈다.
// 이 내원에 상병이 없을 수 있으므로 O_OkdInf 에 행을 가진 내원을 찾아 건다
// (앞서 두 번 밟은 "행 없는 내원으로 판정" 함정).
var okdOwner = FirstComNum(conn, "O_OkdInf", "OkdComNum", "OkdComNum");
if (okdOwner is null)
{
Check("㉕ 상병 — O_OkdInf 에 행이 없어 확인 불가", false,
"이 DB 로는 이 갈래를 확인할 수 없다");
}
else
{
var diag = new DiagnosisStore(conn);
var all = diag.Diagnoses(okdOwner.Value);
var main = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Main);
var sub = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Sub);
lines.Add($" 상병: 내원 {okdOwner.Value:F0} → 전체 {all.Count}행,"
+ $" 주 {main.Count}행, 부 {sub.Count}행");
Check("㉕ 상병 쿼리(전체)가 행을 돌려준다", all.Count > 0,
"OkdStt='E' 조건이나 UNION 결합을 의심할 것");
Check("㉖ 주/부 필터가 실제로 가른다", main.Count + sub.Count == all.Count
&& main.Count <= all.Count,
$"주 {main.Count} + 부 {sub.Count} ≠ 전체 {all.Count} — RN 접기 전 필터 규칙 확인");
if (all.Count > 0)
{
var okdKorNam = all[0].TryGetValue("KcdKorNam", out var kn) ? kn : "";
lines.Add($" 첫 상병: 한글명 {okdKorNam.Length}자,"
+ $" 코드 {(all[0].TryGetValue("KcdElcCod", out var kc) ? kc.Length : 0)}자"
+ " (값은 기록하지 않는다)");
Check("㉗ 상병 한글명이 나온다", okdKorNam.Trim().Length > 0,
"M_KcdMst 조인이 빈 이름을 냈다");
}
}
// ㉘ 수술 — 원문 기계 추출 SQL 이 실제로 돈다.
// 처음엔 O_OprInf 로 소유자를 찾아 늘 SKIP 이었다 — 실제 조회
// (GetOprInfDT)의 축은 S_OprInf(수술 신청)다. 표를 바로잡으니
// 이 시험 DB 에서도 수술 태그가 실검증된다.
var oprOwner = FirstComNum(conn, "S_OprInf", "OprComNum", "OprComNum");
if (oprOwner is null)
{
// "행이 없다"와 "표가 없다/조회가 죽었다"를 가른다 —
// FirstComNum 은 예외를 삼키므로 이 줄 없이는 못 가른다.
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) OPRCNT FROM S_OprInf WHERE :k = 0"))
{
lines.Add($" [진단] S_OprInf: {line}");
}
lines.Add("SKIP ㉘ 수술 — 확인 불가. 위 [진단] 줄이 원인이다:"
+ " ORA-00942 면 이 시험 DB 에 S_OprInf 가 아예 없는 것이고,"
+ " 수술 태그는 운영 DB 에서만 검증할 수 있다");
}
else
{
var surgeries = new SurgeryStore(conn).Surgeries(oprOwner.Value);
var opName = surgeries.Count > 0
&& surgeries[0].TryGetValue("OPNAME", out var on) ? on : "";
lines.Add($" 수술: 내원 {oprOwner.Value:F0} → {surgeries.Count}행,"
+ $" OPNAME {opName.Length}자 (값은 기록하지 않는다)");
Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0,
"OprStt='E' 조건이나 조인을 의심할 것");
}
// ㉙ 바이탈 — E_EmdInf_VITAL 에 행을 가진 내원으로 실행 확인
var vitOwner = FirstComNum(conn,
"E_EmdInf_VITAL INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey",
"EmrComNum", "EmrComNum");
if (vitOwner is null)
{
foreach (var line in ExplainPeriod(conn, 0,
"SELECT COUNT(*) VITCNT FROM E_EmdInf_VITAL WHERE :k = 0"))
{
lines.Add($" [진단] E_EmdInf_VITAL: {line}");
}
lines.Add("SKIP ㉙ 바이탈 — 확인 불가. 위 [진단] 줄이 원인이다"
+ " (ORA-00942 면 이 시험 DB 에 표가 없어 운영 DB 에서만 검증 가능)");
}
else
{
var vital = new VitalStore(conn);
var weight = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight",
new[] { "EmdWeight" }, "EmdDte ASC, EmdTime ASC");
var weightLast = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight",
new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC");
lines.Add($" 바이탈: 내원 {vitOwner.Value:F0} → 첫 몸무게 {weight.Length}자,"
+ $" 마지막 {weightLast.Length}자 (값은 기록하지 않는다)");
Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0,
"EmdWeight 널 필터나 조인을 의심할 것");
}
// ㉚ 병리판독의·진단검사의 — 환자와 무관한 원내 의사 조회.
// 이 시험 DB 에 TLAB 소속·LAB 그룹 의사가 없을 수 있다 —
// 0행은 실패가 아니라 태그가 사유로 완결되는 정상 경로다.
// 여기서 지키는 것은 "예외 없이 돈다"(컬럼·조인 이름이 맞다)이다.
try
{
var contextStore = new PatientContextStore(conn);
var pathologyRow = contextStore.PathologyDoctor();
var labRow = contextStore.LabDoctor();
static string Shape(IReadOnlyDictionary<string, string> row)
=> row.Count == 0 ? "0행"
: $"이름 {(row.TryGetValue("UidNam", out var n) ? n.Trim().Length : 0)}자"
+ $"·전문의번호 {(row.TryGetValue("UidSpcLic", out var s) ? s.Trim().Length : 0)}자";
lines.Add($" 병리판독의(TLAB): {Shape(pathologyRow)}"
+ $" / 진단검사의(LAB그룹): {Shape(labRow)} (값은 기록하지 않는다)");
Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", true, "");
}
catch (Exception ex)
{
Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
// ㉛ 부가정보 7종(자기 SQL) — P_PatEtcInf 에 행이 있는 차트로 돌려
// 컬럼·조인 이름이 실제 DB 와 맞는지 본다. 0값은 실패가 아니라
// 그 환자에 그 정보가 없는 것이다(태그는 사유로 완결된다).
try
{
var extraOwner = FirstText(conn,
"SELECT TRIM(PatEtcChtNum) FROM P_PatEtcInf WHERE ROWNUM = 1");
// 패딩 조사 — P_pdsoInf 는 VARCHAR2 라 저장 형태를 확정할 수 없다.
// IN (:c, RPAD(:c,10)) 이 실제로 필요한지 근거를 남긴다.
var padded = FirstText(conn,
"SELECT COUNT(*) FROM P_pdsoInf WHERE PdsoChtNum <> TRIM(PdsoChtNum)");
var multiEtc = FirstText(conn,
"SELECT NVL(MAX(cnt),0) FROM (SELECT COUNT(*) cnt FROM P_PatEtcInf"
+ " GROUP BY PatEtcChtNum)");
lines.Add($" [조사] P_pdsoInf 패딩 행={padded},"
+ $" P_PatEtcInf 차트당 최대 행수={multiEtc}");
// 협진(O_CstInf)은 O_ 계열이라 이 시험 DB 에 없을 수 있다 —
// 빈 값이면 ORA-00942(운영 DB 전용 검증 대상)라는 뜻이다
var cstCount = FirstText(conn, "SELECT COUNT(*) FROM O_CstInf");
lines.Add(cstCount.Length == 0
? " [조사] O_CstInf 없음 — 협진과 태그는 운영 DB 에서만 검증 가능"
: $" [조사] O_CstInf {cstCount}행 — 협진과 조회 검증 가능");
if (extraOwner.Length == 0)
{
lines.Add("SKIP ㉛ 부가정보 — P_PatEtcInf 가 비어 있어 확인 불가");
}
else
{
var extra = new PatientExtraStore(conn);
var owner2 = FirstComNum(conn, "P_ComInf", "ComNum", "ComNum");
var shapes =
$"실제생년월일 {extra.ActualBirthDate(extraOwner).Length}자"
+ $" · 국적 {extra.Nationality(extraOwner).Length}자"
+ $" · 장애등급 {extra.DisabilityGrade(extraOwner).Length}자"
+ $" · 보호자연락처 {extra.GuardianPhone(extraOwner).Length}자"
+ $" · 급여세대주 {extra.HouseholderNameByInsurance(extraOwner, "0", "1").Length}자"
+ (owner2 is null ? ""
: $" · 건보세대주 {extra.HouseholderName(extraOwner, owner2.Value).Length}자"
+ $" · 협력업체 {extra.Cooperator(owner2.Value).Length}자")
+ $" · FollowUp {extra.FollowUp(extraOwner).Length}자"
+ $" · 최초내원 {extra.FirstVisit(extraOwner, "999912312359").Length}자"
+ $" · 초진일 {extra.FirstVisitByDepartment(extraOwner, "999912312359", "00", false).Length}자"
+ (owner2 is null ? ""
: $" · 외출외박 {extra.LeaveOfAbsence(owner2.Value, false).Length}자"
+ $" · LMP {extra.Lmp(owner2.Value).Length}자"
+ $" · BST {new VitalStore(conn).OneBst(owner2.Value, "EmdDte ASC, EmdTime ASC").Length}자")
+ $" · 혈액형 {extra.BloodType(extraOwner).Length}자";
lines.Add($" 부가정보: {shapes} (값은 기록하지 않는다)");
Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", true, "");
// "돌지만 항상 0행"인 매칭 실수(RPAD·조인)를 가른다 —
// 값이 있는 차트를 골라서 물으면 반드시 값이 나와야 한다.
var natOwner = FirstText(conn,
"SELECT TRIM(PatChtNum) FROM P_PatInf"
+ " INNER JOIN M_DtlMst ON DtlCod = PatNatCod AND DtlTblCod = 'NATCOD'"
+ " WHERE ROWNUM = 1");
if (natOwner.Length == 0)
{
lines.Add("SKIP ㉜ 국적 값 — 이 DB 에 국적 코드가 잡힌 환자가 없어 판정 불가");
}
else
{
Check("㉜ 국적 코드가 있는 차트에서는 값이 나온다",
extra.Nationality(natOwner).Length > 0,
"RPAD 매칭이나 NATCOD 조인을 의심할 것");
}
// 나이 UDF — 주민번호가 있는 차트로 물으면 값이 나와야 한다.
// (통합 대표행의 AgeMonth/AGE 파생 컬럼과 같은 식 —
// UDF 이름·접두가 틀리면 여기서 바로 죽는다)
var ageOwner = FirstText(conn,
"SELECT TRIM(PatChtNum) FROM P_PatInf"
+ " WHERE PatResEnc IS NOT NULL AND ROWNUM = 1");
if (ageOwner.Length == 0)
{
lines.Add("SKIP ㊱ 나이 UDF — 주민번호가 있는 환자가 없어 판정 불가");
}
else
{
var ageText = extra.AgeMonthText(ageOwner, "202608201200");
var ageYears = extra.AgeYearsUdf(ageOwner, "20260820");
lines.Add($" 나이 UDF: 개월표기 {ageText.Length}자 · 정수 {ageYears.Length}자 (값은 기록하지 않는다)");
Check("㊱ 나이 UDF 가 값을 돌려준다",
ageText.Length > 0 && ageYears.Length > 0,
"UDF_GETAGE_FROMRESNUM/UDF_GETAGE 이름·접두를 의심할 것");
}
// 최초내원일도 같은 방식 — 재진(ComNfsTyp='2') 내원이 있는
// 차트를 골라서 물으면 반드시 값이 나와야 한다.
var revisitOwner = FirstText(conn,
"SELECT TRIM(ComChtNum) FROM P_ComInf"
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
+ " WHERE ComAcpStt <> 'OC' AND ComNfsTyp = '2' AND ROWNUM = 1");
if (revisitOwner.Length == 0)
{
lines.Add("SKIP ㉝ 최초내원일 값 — 재진 내원이 없어 판정 불가");
}
else
{
Check("㉝ 재진 내원이 있는 차트에서는 최초내원일이 나온다",
extra.FirstVisit(revisitOwner, "999912312359").Length >= 8,
"RPAD 매칭이나 P_CodInf 조인을 의심할 것");
}
// 협진도 같은 방식 — 회신 완료 협진이 있는 내원으로 물으면
// 과 이름이 반드시 나와야 한다(조인 회귀를 잡는다).
var consultOwner = FirstText(conn,
"SELECT CstComNum FROM (SELECT CstComNum FROM O_CstInf"
+ " INNER JOIN M_DepMst ON CstRplDep = DepCod"
+ " AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
+ " WHERE CstSttFlg = 'G') WHERE ROWNUM = 1");
if (consultOwner.Length == 0 || !decimal.TryParse(consultOwner, out var cstCom))
{
lines.Add("SKIP ㉞ 협진과 값 — 회신 완료 협진이 없어 판정 불가");
}
else
{
var consults = new PatientContextStore(conn).Consults(cstCom, false);
lines.Add($" 협진: 내원 {cstCom:F0} → {consults.Count}건 (값은 기록하지 않는다)");
Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다",
consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것");
}
// ㉟ 부가정보 2차분 — 새 자기 SQL 12종이 예외 없이 돈다.
// O_ 계열 표(O_OkdInf·O_ImlInf·O_OutAdmInf)가 이 시험 DB 에
// 없으면 개별 [진단] 줄로 남기고 판정은 실행 가능한 것만 본다.
try
{
var probes = new (string Probe, Func<string> Run)[]
{
("알러지", () => extra.Allergy(extraOwner, null).Length.ToString()),
("알러지목록", () => extra.AllergyRows(extraOwner).Count.ToString()),
("보험증번호", () => extra.InsuranceCertNumber(extraOwner).Length.ToString()),
("재해발생일", () => extra.AccidentDate(extraOwner).Length.ToString()),
("감염정보", () => extra.InfectionInfo(extraOwner).Length.ToString()),
("발병일", () => owner2 is null ? "-" : extra.OnsetDate(extraOwner, owner2.Value).Length.ToString()),
("당일식이", () => owner2 is null ? "-" : extra.TodayMeals(owner2.Value).Count.ToString()),
("외출예상", () => extra.ExpectedOuting(extraOwner, false).Length.ToString()),
("퇴원분석", () => owner2 is null ? "-" : extra.DischargeAnalysisOperations(owner2.Value).Count.ToString()),
("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()),
("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()),
("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()),
("계산유형", () => extra.CalculationTypeName("1", "01", "20260101").Length.ToString()),
("보험유형", () => extra.InsuranceTypeName("1", "20260101").Length.ToString()),
("수납일시", () => owner2 is null ? "-" : extra.ReceiptStamp(owner2.Value, "0", "20260101").Length.ToString()),
("진료기간시작", () => extra.TreatmentPeriodStart(extraOwner, "999912312359").Length.ToString()),
("퇴원약오더", () => owner2 is null ? "-" : extra.DischargeMedicationOrderDate(owner2.Value).Length.ToString()),
("응급증상", () => owner2 is null ? "-" : (extra.EmergencySymptomInsYon(owner2.Value) ?? "[없음]")),
("전과내역", () => owner2 is null ? "-" : extra.TransferHistory(owner2.Value).Count.ToString()),
("당뇨식이", () => extra.DiabetesMealCalorie(extraOwner).Length.ToString()),
("수술일자", () => owner2 is null ? "-" : extra.SurgeryDate(owner2.Value, false).Length.ToString()),
("외래실진료일", () => extra.OutpatientVisitDays(extraOwner, "999912312359", "05", false).Count.ToString()),
("청구과목", () => extra.BillingDepartmentName("0506").Length.ToString()),
("퇴원약목록", () => owner2 is null ? "-" : extra.DischargeMedications(owner2.Value).Count.ToString()),
("수술집도의과", () => owner2 is null ? "-" : extra.SurgeonDepartment(owner2.Value, false).Length.ToString()),
("수술명칭", () => owner2 is null ? "-" : extra.SurgeryNames(owner2.Value).Count.ToString()),
("비급여", () => owner2 is null ? "-" : extra.NonInsuredOrders(owner2.Value).Count.ToString()),
("특수계산코드", () => extra.SpecialNonInsuredCalCodes().Count.ToString()),
("그룹과목", () => extra.GroupSubjectRow("0506").Count.ToString()),
("임신정보", () => owner2 is null ? "-" : extra.PregnancyRow(owner2.Value).Count.ToString()),
("낮병동구간", () => owner2 is null ? "-" : extra.DayCareRange(owner2.Value).Count.ToString()),
("수간호사", () => extra.WardHeadNurse("0506").Length.ToString()),
("적용진료과", () => extra.AppliedDepartmentName("0506", "202601011200").Length.ToString()),
("전과목록", () => owner2 is null ? "-" : extra.TransferList(owner2.Value).Count.ToString()),
("간호과거력", () => owner2 is null ? "-" : extra.NurseHistoryRow(owner2.Value).Count.ToString()),
};
var shapes2 = new List<string>();
var failed = new List<string>();
foreach (var (probe, run) in probes)
{
try
{
shapes2.Add($"{probe} {run()}");
}
catch (Exception ex)
{
var first = ex.Message.Split('\n')[0];
// 표 자체가 없는 것(ORA-00942)은 시험 DB 한계다
if (first.Contains("ORA-00942"))
{
shapes2.Add($"{probe} [표없음]");
}
else
{
failed.Add($"{probe}: {first}");
}
}
}
lines.Add($" 부가정보2: {string.Join(" · ", shapes2)}");
foreach (var f in failed)
{
lines.Add($" [진단] {f}");
}
Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", failed.Count == 0,
"위 [진단] 줄의 컬럼·조인 이름을 의심할 것");
}
catch (Exception ex)
{
Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
}
}
catch (Exception ex)
{
Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", false,
$"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}");
}
}
}
}
}
} }
lines.Add(string.Empty); lines.Add(string.Empty);
@@ -1903,6 +2545,85 @@ public static class DbSmoke
} }
} }
/// <summary>
/// 이 표에 행을 가진 내원 하나 — 기간 조회 갈래를 실제로 통과시켜 보려면 재료가 필요하다.
/// 최근 것을 고른다(오래된 내원은 구간이 이미 닫혀 있어 판정이 덜 대표적이다).
/// <b>표 이름·컬럼 이름은 호출부의 리터럴만 들어온다</b> — 사용자 입력이 닿는 경로가 아니다.
/// </summary>
/// <summary>스칼라 한 칸을 문자열로 — 0행이거나 죽으면 빈 문자열(판정은 호출부가 한다)</summary>
private static string FirstText(string connectionString, string sql)
{
try
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = sql;
var value = command.ExecuteScalar();
return value is null or DBNull ? string.Empty : value.ToString()!.Trim();
}
catch
{
return string.Empty;
}
}
private static decimal? FirstComNum(string connectionString, string table, string key, string stamp)
{
try
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.CommandText =
$"SELECT {key} FROM (SELECT {key} FROM {table} ORDER BY {stamp} DESC) WHERE ROWNUM = 1";
var value = command.ExecuteScalar();
return value is null or DBNull ? null : Convert.ToDecimal(value);
}
catch
{
return null;
}
}
/// <summary>
/// 기간 조회가 0행일 때 <b>왜</b> 0행인지 보여 준다 — 조건을 뺀 원본 행의 구간을 그대로 찍는다.
/// 값이 아니라 구간만 읽으므로 개인정보를 꺼내지 않는다.
/// </summary>
private static List<string> ExplainPeriod(string connectionString, decimal comNum, string sql)
{
var rows = new List<string>();
try
{
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString);
connection.Open();
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText = sql;
command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", comNum));
using var reader = command.ExecuteReader();
while (reader.Read())
{
var cells = new string[reader.FieldCount];
for (var i = 0; i < reader.FieldCount; i++)
{
cells[i] = $"{reader.GetName(i)}="
+ (reader.IsDBNull(i) ? "(null)" : $"'{reader.GetValue(i).ToString()!.Trim()}'");
}
rows.Add(string.Join(" ", cells));
}
if (rows.Count == 0)
{
rows.Add("(원본 행 자체가 없다 — 조건 문제가 아니라 이 내원에 그 정보가 없는 것이다)");
}
}
catch (Exception ex)
{
rows.Add($"진단 실패: {ex.GetType().Name}: {ex.Message}");
}
return rows;
}
public static int RunModifySmoke(string shtCod, string reportPath) public static int RunModifySmoke(string shtCod, string reportPath)
{ {
var lines = new List<string>(); var lines = new List<string>();
@@ -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 커맨드를 안 돌리므로 목록이 비어 있다 —
// 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다. // 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다.
@@ -421,6 +436,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 = "MDataTable", 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),
+381 -5
View File
@@ -60,6 +60,43 @@ public static class EditSmoke
SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag)); SheetMe.Core.Catalog.TagPreviewCatalog.ReasonText(tag));
} }
/// <summary>
/// 검사용 배선 해석기 — 표 한 장을 손으로 쥐고 네 갈래(정상·컬럼오타·없는소스·형식깨짐)를 만든다.
/// 실행기(<see cref="Services.MDataTableRunner"/>)를 쓰면 DB 접속 여부에 판정이 묶인다.
/// </summary>
private sealed class StubFieldResolver : SheetMe.Core.Catalog.IDataFieldResolver
{
public SheetMe.Core.Catalog.TagValue Resolve(SheetMe.Core.Catalog.DataTableFieldSpec spec)
{
if (spec.TableName != "MDataTable1")
{
return new SheetMe.Core.Catalog.TagValue(false,
$"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
}
return spec.Field == "NAM"
? new SheetMe.Core.Catalog.TagValue(true, "ㄱ")
: new SheetMe.Core.Catalog.TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다");
}
}
/// <summary>시각 트리에서 첫 TextBlock — 없으면 null</summary>
private static System.Windows.Controls.TextBlock? FindFirstTextBlock(DependencyObject root)
{
if (root is System.Windows.Controls.TextBlock block)
{
return block;
}
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
if (FindFirstTextBlock(System.Windows.Media.VisualTreeHelper.GetChild(root, i)) is { } found)
{
return found;
}
}
return null;
}
public static int Run(string reportPath) public static int Run(string reportPath)
{ {
var lines = new List<string>(); var lines = new List<string>();
@@ -407,6 +444,37 @@ public static class EditSmoke
$"요소 {livePages} → {liveAfterAdd} (안 늘면 생성자에서 한 번만 그린 것)"); $"요소 {livePages} → {liveAfterAdd} (안 늘면 생성자에서 한 번만 그린 것)");
live.Close(); live.Close();
// 종이 글자는 Display 로 바뀌지 않았는가 — 대조군이자 회귀 방어다.
// 화면 UI 를 Display 로 돌리면서 종이까지 끌려가면 글리프 자릿수가 달라져
// 레거시와 다른 지면이 된다. --db-render md5 가 잠그고 있지만
// 그건 서식 하나뿐이므로 여기서 속성 값으로도 못 박는다.
{
var paperPage = new FormDocument();
var paperOnly = new FormPage { Width = 720, Height = 856 };
paperPage.Pages.Add(paperOnly);
var paperLabel = new ControlElement
{
Type = "Label", Id = "종이글자",
Bounds = new LayoutRect { X = 20, Y = 20, W = 200, H = 20 },
};
paperLabel.Props.SetText("Text", "종이");
paperOnly.Controls.Add(paperLabel);
var paperVm = new DesignerViewModel(paperPage);
var paperVisual = (System.Windows.FrameworkElement)
Services.PrintService.BuildPageVisual(paperVm.Pages[0]);
paperVisual.Measure(new System.Windows.Size(720, 856));
paperVisual.Arrange(new Rect(0, 0, 720, 856));
paperVisual.UpdateLayout();
var paperBlock = FindFirstTextBlock(paperVisual);
Check("종이 글자는 Ideal 을 유지한다(화면 Display 와 분리)",
paperBlock is not null
&& System.Windows.Media.TextOptions.GetTextFormattingMode(paperBlock)
== System.Windows.Media.TextFormattingMode.Ideal,
paperBlock is null
? "종이에서 TextBlock 을 못 찾아 판정 불가"
: System.Windows.Media.TextOptions.GetTextFormattingMode(paperBlock).ToString());
}
// 태그가 종이에서 값이 되는가. // 태그가 종이에서 값이 되는가.
// 지금까지 미리보기는 태그를 글자 그대로 찍었다 — PAT_이름 이 종이에 그렇게 인쇄됐다. // 지금까지 미리보기는 태그를 글자 그대로 찍었다 — PAT_이름 이 종이에 그렇게 인쇄됐다.
// 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 <b>사유</b>로 바뀌어야 한다 // 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 <b>사유</b>로 바뀌어야 한다
@@ -444,15 +512,323 @@ public static class EditSmoke
&& !noResolver.Contains("[", StringComparison.Ordinal), && !noResolver.Contains("[", StringComparison.Ordinal),
noResolver); noResolver);
// 값이 나오는 태그는 값으로, 안 나오는 태그는 사유 // 값이 나오는 태그는 값으로, 안 나오는 태그는 <b>빈칸</b>으
// (2026-08-20 사용자 결정 — 출력물에 [태그 — 사유] 가 찍히면 안 된다.
// 태그 이름이 남아도 안 된다: 값이 나온 줄 알게 된다)
var withResolver = PaperText(new StubTagResolver()); var withResolver = PaperText(new StubTagResolver());
Check("태그 값: 값이 나오는 태그는 값이 찍힌다", Check("태그 값: 값이 나오는 태그는 값이 찍힌다",
withResolver.Contains("2026-08-18", StringComparison.Ordinal), withResolver); withResolver.Contains("2026-08-18", StringComparison.Ordinal), withResolver);
Check("태그 값: 못 만드는 태그는 사유가 찍힌다", Check("태그 값: 못 만드는 태그는 빈칸이 된다",
withResolver.Contains("PAT_차트번호", StringComparison.Ordinal) !withResolver.Contains("PAT_차트번호", StringComparison.Ordinal)
&& withResolver.Contains("환자", StringComparison.Ordinal) && !withResolver.Contains("[", StringComparison.Ordinal),
&& withResolver.Contains("[", StringComparison.Ordinal),
withResolver); withResolver);
// ── 환자 문맥을 붙였을 때 ──
// 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고,
// 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다").
// DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다.
static SheetMe.Data.Stores.PatientContext Fake(string chart, string name)
{
var patInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["PATNAM"] = name,
};
// 차트번호는 <b>내원</b>이 들고 있다 — 레거시 moPatInfoBiz.ChtNum 이 그것이다
// (bzDataInterface.vb:70). 여기를 PatInf 로 두면 실제와 다른 것을 검사한다.
var comInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["COMCHTNUM"] = chart,
};
var blank = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
return new SheetMe.Data.Stores.PatientContext(
90001m, "202608181500", patInf, comInf, blank, blank, blank);
}
var patientDoc = new FormDocument();
var patientPage = new FormPage { Width = 720, Height = 856 };
patientDoc.Pages.Add(patientPage);
// 넷을 한 종이에 둔다: 매핑된 것 둘, 매핑 안 된 환자 태그 하나, 시각 태그 하나.
// 하나만 두면 "값이 나온다"만 보고 나머지 갈래가 조용히 깨져도 모른다.
var probes = new[]
{
("차트", "PAT_차트번호"), ("이름", "PAT_이름"),
("간호", "ETC_수술실간호사"), ("시각", "ETC_현재일자"),
};
for (var i = 0; i < probes.Length; i++)
{
var probe = new ControlElement
{
Type = "TextBox", Id = probes[i].Item1,
Bounds = new LayoutRect { X = 40, Y = 40 + (i * 40), W = 260, H = 20 },
};
probe.Props.SetText("Text", probes[i].Item2);
probe.Props.SetText("DataInterfaceTag", probes[i].Item2);
patientPage.Controls.Add(probe);
}
var patientVm = new DesignerViewModel(patientDoc);
string PatientPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver)
{
var visual = (System.Windows.FrameworkElement)
Services.PrintService.BuildPageVisual(patientVm.Pages[0], true, resolver);
visual.Measure(new System.Windows.Size(720, 856));
visual.Arrange(new Rect(0, 0, 720, 856));
visual.UpdateLayout();
return CollectText(visual);
}
// ── 옮긴 태그 이름이 <b>실존하는가</b> ──
// 이 검사가 없어서 지어낸 이름 셋(PAT_휴대전화·PAT_전화번호·PAT_주소)이
// 표에 앉아 조용히 아무것도 안 하고 있었다. 이름이 틀리면 매칭이 안 되므로
// 값도 안 나오고 오류도 안 난다 — 화면에는 "아직 안 옮겼습니다"로 보인다.
var bogus = Services.PatientTagResolver.MappedTags
.Where(t => !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains(t))
.ToList();
Check("환자 태그: 옮긴 이름이 모두 실제 태그다", bogus.Count == 0,
$"카탈로그에 없는 이름: {string.Join(", ", bogus)}");
// 대조군 — 없는 이름이 실제로 걸리는지. 통과만 보면 검사가 도는지 알 수 없다.
Check("환자 태그(대조군): 없는 이름은 걸러진다",
!SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_주소"));
// 대조군 — 환자 없이. 이 줄이 없으면 아래 판정은 "원래 그랬던 것"과 구분되지 않는다.
// 2026-08-20 사용자 결정으로 못 만든 태그는 종이에서 <b>빈칸</b>이다 —
// 값도, 태그 이름도, 사유도 찍히면 안 된다.
var withoutPatient = PatientPaper(new StubTagResolver());
Check("환자 태그(대조군): 환자가 없으면 빈칸이다",
!withoutPatient.Contains("C-9001", StringComparison.Ordinal)
&& !withoutPatient.Contains("PAT_차트번호", StringComparison.Ordinal)
&& !withoutPatient.Contains("PAT_이름", StringComparison.Ordinal),
withoutPatient);
var withPatient = PatientPaper(
new Services.PatientTagResolver(Fake("C-9001", "가상환자"), new StubTagResolver()));
Check("환자 태그: 문맥을 붙이면 값이 찍힌다",
withPatient.Contains("C-9001", StringComparison.Ordinal)
&& withPatient.Contains("가상환자", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_차트번호", StringComparison.Ordinal)
&& !withPatient.Contains("[PAT_이름", StringComparison.Ordinal),
withPatient);
// 아직 안 옮긴 환자 태그는 종이에서 빈칸이지만 <b>해석기는 그렇다고 말해야</b> 한다 —
// 사유는 진단·도구가 쓴다("DB 접속이 없습니다" 로 뭉개면 접속을 의심하게 된다).
// 대조군 태그가 이식되면 이 판정이 깨진다 — 그때 다른 미이식 태그로 갈 것(PAT_나이에서 한 번 갈았다).
var unmappedOnPaper = new Services.PatientTagResolver(
Fake("C-9001", "가상환자"), new StubTagResolver()).Resolve("ETC_수술실간호사");
Check("환자 태그: 매핑 없는 환자 태그는 안 옮겼다고 말한다(해석기)",
!unmappedOnPaper.Resolved
&& unmappedOnPaper.Text.Contains("아직 옮기지", StringComparison.Ordinal),
unmappedOnPaper.Text);
Check("환자 태그: 매핑 없는 환자 태그는 종이에서 빈칸이다",
!withPatient.Contains("ETC_수술실간호사", StringComparison.Ordinal)
&& !withPatient.Contains("아직 옮기지", StringComparison.Ordinal),
withPatient);
// 사슬이 끊기지 않았는가 — 환자를 붙였다고 시각 태그가 죽으면 안 된다
Check("환자 태그: 앞 해석기(시각·사용자)가 계속 산다",
withPatient.Contains("2026-08-18", StringComparison.Ordinal), withPatient);
// 컬럼이 비어 있으면 값이 아니라 사유. 빈칸을 값으로 찍으면
// "이 환자는 이름이 없다"와 "태그가 고장났다"를 화면에서 구분할 수 없다.
// ── DataTableField 배선이 종이에서 값이 되는가 ──
// 서식의 값 상당수는 태그가 아니라 이 관으로 온다(운영 실측 Rows 형 1,624건).
// 태그만 채워 보면 "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
var fieldDoc = new FormDocument();
var fieldPage = new FormPage { Width = 720, Height = 856 };
fieldDoc.Pages.Add(fieldPage);
var source = new ControlElement
{
Type = "MDataTable", Id = "MDataTable1",
Bounds = new LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
};
source.Props.SetText("Query", "SELECT 'ㄱ' NAM FROM DUAL");
fieldPage.Controls.Add(source);
foreach (var (id, wiring, y) in new[]
{
("정상", "MDataTable1.Rows(0).Item(\"NAM\")", 40),
("컬럼오타", "MDataTable1.Rows(0).Item(\"NAMX\")", 80),
("없는소스", "MDataTable9.Rows(0).Item(\"NAM\")", 120),
("형식깨짐", "MDataTable1.Rows(0)", 160),
})
{
var wired = new ControlElement
{
Type = "TextBox", Id = id,
Bounds = new LayoutRect { X = 40, Y = y, W = 300, H = 20 },
};
wired.Props.SetText("Text", id);
wired.Props.SetText("DataTableField", wiring);
fieldPage.Controls.Add(wired);
}
var fieldVm = new DesignerViewModel(fieldDoc);
string FieldPaper(SheetMe.Core.Catalog.IDataFieldResolver? resolver)
{
var visual = (System.Windows.FrameworkElement)
Services.PrintService.BuildPageVisual(fieldVm.Pages[0], true, null, resolver);
visual.Measure(new System.Windows.Size(720, 856));
visual.Arrange(new Rect(0, 0, 720, 856));
visual.UpdateLayout();
return CollectText(visual);
}
// 대조군 — 해석기가 없으면 지금까지와 같다(배선 문자열이 아니라 Text 가 그대로)
var noFields = FieldPaper(null);
Check("배선(대조군): 해석기가 없으면 종이가 그대로다",
noFields.Contains("정상", StringComparison.Ordinal)
&& !noFields.Contains("[", StringComparison.Ordinal),
noFields);
// 표를 직접 쥐고 네 갈래를 한 번에 본다 — DB 없이 판정해야 진단이 접속에 안 묶인다.
// 2026-08-20 부터 종이는 못 만든 값을 빈칸으로 두므로, 사유는 해석기 수준에서
// 판정하고 종이에서는 사유가 새지 않았는지를 본다.
var stub = new StubFieldResolver();
var withFields = FieldPaper(stub);
Check("배선: 맞는 배선은 값이 찍힌다",
withFields.Contains("ㄱ", StringComparison.Ordinal), withFields);
SheetMe.Core.Catalog.TagValue FieldValue(string wiring)
{
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring);
return spec is null
? new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다")
: stub.Resolve(spec);
}
var wrongColumn = FieldValue("MDataTable1.Rows(0).Item(\"NAMX\")");
Check("배선: 컬럼명이 틀리면 그 컬럼이 없다고 말한다(해석기)",
!wrongColumn.Resolved
&& wrongColumn.Text.Contains("NAMX", StringComparison.Ordinal)
&& wrongColumn.Text.Contains("없습니다", StringComparison.Ordinal),
wrongColumn.Text);
var missingSource = FieldValue("MDataTable9.Rows(0).Item(\"NAM\")");
Check("배선: 없는 데이터소스를 가리키면 이름을 말한다(해석기)",
!missingSource.Resolved
&& missingSource.Text.Contains("MDataTable9", StringComparison.Ordinal),
missingSource.Text);
// 레거시 런타임은 형식 오류를 빈 catch 로 삼켜 아무 표시 없이 빈칸이 된다.
// 종이는 이제 우리도 빈칸이지만, 해석기가 사유를 담아 진단·도구가 잡을 수 있다.
var brokenSpec = FieldValue("MDataTable1.Rows(0)");
Check("배선: 형식이 깨지면 읽을 수 없다고 말한다(해석기)",
!brokenSpec.Resolved
&& brokenSpec.Text.Contains("읽을 수 없습니다", StringComparison.Ordinal),
brokenSpec.Text);
Check("배선: 못 만든 배선은 종이에서 빈칸이다",
!withFields.Contains("없습니다", StringComparison.Ordinal)
&& !withFields.Contains("MDataTable9", StringComparison.Ordinal)
&& !withFields.Contains("[", StringComparison.Ordinal),
withFields);
// 러너가 서식에서 데이터소스를 모으는가 — 이름을 못 모으면 배선이 전부 '없는 소스'가 된다.
// 패널 안에 있는 것도 모아야 한다(운영 서식은 대개 패널 안에 넣는다).
var runner = new Services.MDataTableRunner(fieldDoc, null);
Check("배선: 서식에서 데이터소스 이름을 모은다",
runner.Names.Count == 1 && runner.Names[0] == "MDataTable1",
string.Join(", ", runner.Names));
Check("배선: 환자 없이 만든 러너는 문맥이 없다고 말한다", !runner.HasContext);
// ── 미리보기 창을 <b>통해서</b> 값이 흐르는가 ──
// 위 판정은 해석기와 인쇄 투영을 직접 붙여 본 것이다. 실제 앱에서는 창이 해석기를
// 들고 있고 다시 그린다 — 그 배선이 끊기면 "환자를 골랐는데 종이가 그대로"가 된다.
// 모달은 진단에서 띄울 수 없으므로 고르는 일과 붙이는 일을 나눠 두고 붙이는 쪽만 부른다.
var patientPreview = new Views.PreviewWindow(patientVm);
patientPreview.Show();
string PreviewPaper()
{
patientPreview.UpdateLayout();
return CollectText(patientPreview);
}
var previewBefore = PreviewPaper();
patientPreview.UsePatient(Fake("C-9003", "창환자"), "표본");
var previewAfter = PreviewPaper();
Check("미리보기 창: 환자를 붙이면 종이가 값으로 바뀐다",
!previewBefore.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("C-9003", StringComparison.Ordinal)
&& previewAfter.Contains("창환자", StringComparison.Ordinal),
$"붙이기 전 {previewBefore.Contains("C-9003", StringComparison.Ordinal)}"
+ $" / 붙인 뒤 {previewAfter.Contains("C-9003", StringComparison.Ordinal)}");
// 뗐을 때 되돌아가는가 — 안 되돌아가면 화면이 없는 환자의 값을 계속 보여 준다.
// 미리보기 창은 하나만 뜨므로 그 잔상은 <b>다음 사람이</b> 보게 된다.
patientPreview.UsePatient(null, string.Empty);
var previewCleared = PreviewPaper();
Check("미리보기 창: 환자를 떼면 값이 사라진다",
!previewCleared.Contains("C-9003", StringComparison.Ordinal)
&& !previewCleared.Contains("PAT_차트번호", StringComparison.Ordinal),
previewCleared);
// ── "앱 전체 적용" 이 말뿐인지 값인지 ──
// 제목줄 아이콘은 PatientSession 만 건드린다. 열려 있는 창들이 그것을 <b>듣지 않으면</b>
// 아이콘은 켜지는데 종이는 그대로다 — 사용자에게는 "설정했는데 안 먹는다"가 된다.
// 세션에만 쓰고 종이가 따라오는지 본다(창의 메서드를 직접 부르지 않는다).
Services.PatientSession.Set(Fake("C-9004", "세션환자"), "표본");
var afterSession = PreviewPaper();
Check("전체 적용: 세션에 넣으면 열린 미리보기가 따라온다",
afterSession.Contains("C-9004", StringComparison.Ordinal)
&& afterSession.Contains("세션환자", StringComparison.Ordinal),
afterSession);
Services.PatientSession.Set(null, string.Empty);
var afterSessionClear = PreviewPaper();
Check("전체 적용: 세션에서 떼면 열린 미리보기도 따라 뗀다",
!afterSessionClear.Contains("C-9004", StringComparison.Ordinal)
&& !afterSessionClear.Contains("PAT_차트번호", StringComparison.Ordinal),
afterSessionClear);
// ── 미리보기와 인쇄가 <b>같은 종이</b>인가 ──
// 인쇄 경로(BuildFixedDocument)가 해석기 없이 BuildPageVisual 을 불러서
// 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로였다 —
// 실제 인쇄 PDF 를 화면과 나란히 놓고서야 발견했다. 이 판정이 없어서 못 잡은 것이다.
string PrintPaper(SheetMe.Core.Catalog.ITagValueResolver? resolver)
{
var fixedDoc = Services.PrintService.BuildFixedDocument(
patientVm, resolver, null);
var fixedPage = fixedDoc.Pages[0].Child;
fixedPage.Measure(new System.Windows.Size(720, 856));
fixedPage.Arrange(new Rect(0, 0, 720, 856));
fixedPage.UpdateLayout();
return CollectText(fixedPage);
}
var printResolver = new Services.PatientTagResolver(
Fake("C-9006", "인쇄환자"), new StubTagResolver());
// 대조군 — 해석기 없이 뽑으면 값이 없어야 한다. 이 줄이 없으면 아래 판정은
// "원래 값이 어디서든 나온다"와 구분되지 않는다.
var printBlind = PrintPaper(null);
Check("인쇄(대조군): 해석기가 없으면 값이 없다",
!printBlind.Contains("C-9006", StringComparison.Ordinal), printBlind);
var printPaper = PrintPaper(printResolver);
var previewPaperSame = PatientPaper(printResolver);
Check("인쇄: 해석기를 넘기면 환자 값이 인쇄 종이에 찍힌다",
printPaper.Contains("C-9006", StringComparison.Ordinal)
&& printPaper.Contains("인쇄환자", StringComparison.Ordinal),
printPaper);
Check("인쇄: 미리보기 종이와 인쇄 종이의 글자가 같다",
printPaper == previewPaperSame,
$"미리보기 {previewPaperSame.Length}자 vs 인쇄 {printPaper.Length}자");
// 창을 닫은 뒤에도 이벤트가 남아 있으면 회수되지 않고, 죽은 창이 계속 그린다.
// 닫고 다시 세션을 건드려 <b>예외 없이</b> 지나가는지 본다(구독이 남아 있으면
// 죽은 창의 PagesHost 를 만지다 터진다).
patientPreview.Close();
Services.PatientSession.Set(Fake("C-9005", "닫힌뒤"), "표본");
Services.PatientSession.Set(null, string.Empty);
Check("전체 적용: 창을 닫으면 구독이 떨어진다", true, "예외 없이 통과");
// 값이 비면 종이는 빈칸이지만 해석기는 사유를 담아야 한다 —
// "이 환자는 이름이 없다"와 "태그가 고장났다"를 진단에서 구분하는 근거다
var emptyNameResolver = new Services.PatientTagResolver(
Fake("C-9002", string.Empty), new StubTagResolver());
var emptyNameValue = emptyNameResolver.Resolve("PAT_이름");
Check("환자 태그: 값이 비면 사유를 밝힌다(해석기)",
!emptyNameValue.Resolved
&& emptyNameValue.Text.Contains("비어", StringComparison.Ordinal),
emptyNameValue.Text);
var emptyName = PatientPaper(emptyNameResolver);
Check("환자 태그: 값이 비면 종이에서는 빈칸이다",
emptyName.Contains("C-9002", StringComparison.Ordinal)
&& !emptyName.Contains("비어", StringComparison.Ordinal)
&& !emptyName.Contains("PAT_이름", StringComparison.Ordinal),
emptyName);
} }
// 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖) // 2) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
@@ -0,0 +1,165 @@
using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
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)
{
if (control.Type == "MDataTable")
{
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,307 @@
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<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,84 @@
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))
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
#endregion
}
File diff suppressed because it is too large Load Diff
+97 -22
View File
@@ -16,20 +16,44 @@ 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)
{ {
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);
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)
{ {
var document = new FixedDocument(); var document = new FixedDocument();
foreach (var page in designer.Pages) foreach (var page in designer.Pages)
@@ -40,7 +64,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));
var pageContent = new PageContent(); var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage); ((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -59,7 +85,8 @@ 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)
{ {
var canvas = new Canvas var canvas = new Canvas
{ {
@@ -91,7 +118,7 @@ public static class PrintService
} }
var presenter = new ContentPresenter var presenter = new ContentPresenter
{ {
Content = PrintProjection(control, printFilter, tags), Content = PrintProjection(control, printFilter, tags, fields),
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 +148,12 @@ 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)
{ {
if (control is not ContainerViewModel container) if (control is not ContainerViewModel container)
{ {
return WithTagValue(control, tags); return WithTagValue(control, tags, fields);
} }
ContainerViewModel? copy = container switch ContainerViewModel? copy = container switch
{ {
@@ -145,7 +173,7 @@ public static class PrintService
{ {
continue; continue;
} }
if (PrintProjection(child, printFilter, tags) is ControlViewModel projected) if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected)
{ {
copy.Children.Add(projected); copy.Children.Add(projected);
} }
@@ -165,28 +193,75 @@ 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)
{ {
if (tags is null) // 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
var (label, value) = Bound(control, tags, fields);
if (label is null)
{ {
return control; return control;
} }
var tag = control.Model.Props.GetText("DataInterfaceTag"); // 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
if (tag is not { Length: > 0 } || tag == "None") // ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{ {
return control; return control;
} }
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", 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>
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 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 ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다");
+23 -5
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>
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== --> <!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
@@ -647,11 +664,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>
@@ -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)
{ {
@@ -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 ThemedWindow}">
<DockPanel Margin="12">
<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"/>
</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
}
+20 -5
View File
@@ -25,7 +25,6 @@
처리하며, 여기 올리면 인스펙터·검색 상자 등 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}"/>
@@ -164,7 +163,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 +294,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}"/>
@@ -752,9 +758,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 +776,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>
@@ -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,69 @@ 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;
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : null);
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)
{ {
@@ -0,0 +1,146 @@
<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 ThemedWindow}">
<DockPanel Margin="12">
<!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치,
성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. -->
<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="92" 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"/>
<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="52" DisplayMemberBinding="{Binding Kind}"/>
<GridViewColumn Header="접수일시" Width="118" DisplayMemberBinding="{Binding AcceptedAtText}"/>
<GridViewColumn Header="진료과" Width="100" DisplayMemberBinding="{Binding Department}"/>
<GridViewColumn Header="담당의" Width="80" DisplayMemberBinding="{Binding Doctor}"/>
<GridViewColumn Header="보험" Width="86" DisplayMemberBinding="{Binding Insurance}"/>
<GridViewColumn Header="상태" Width="66" DisplayMemberBinding="{Binding State}"/>
</GridView>
</ListView.View>
</ListView>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -0,0 +1,232 @@
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);
}
}
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);
PatientList.ItemsSource = found;
PatientCountText.Text = found.Count switch
{
0 => "환자 — 없습니다",
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
}
@@ -21,6 +21,26 @@
Checked="OnViewToggled" Unchecked="OnViewToggled" Checked="OnViewToggled" Unchecked="OnViewToggled"
ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/> ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/>
<Separator/> <Separator/>
<!--
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
-->
<Button x:Name="PatientButton" Content="환자 선택..." Padding="10,3" Click="OnPickPatient"
ToolTip="실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
<Button x:Name="PatientClearButton" Content="환자 해제" Padding="10,3" Click="OnClearPatient"
IsEnabled="False" ToolTip="환자 없이 — 태그는 사유만 보입니다"/>
<!--
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
-->
<Button x:Name="DataSourceButton" Content="데이터소스..." Padding="10,3" Click="OnShowDataSources"
IsEnabled="False" ToolTip="MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"/>
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
MaxWidth="360" TextTrimming="CharacterEllipsis"
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
<Separator/>
<Button Content="" Padding="8,3" Click="OnZoomOut"/> <Button Content="" Padding="8,3" Click="OnZoomOut"/>
<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 Content="" Padding="8,3" Click="OnZoomIn"/>
@@ -28,10 +28,26 @@ 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;
#endregion #endregion
#region Constructors #region Constructors
@@ -42,18 +58,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 +112,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,7 +168,7 @@ 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),
Width = page.WidthDip, Width = page.WidthDip,
Height = page.HeightDip, Height = page.HeightDip,
}; };
@@ -156,6 +181,85 @@ public partial class PreviewWindow : Window
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages(); private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
/// <summary>
/// 실제 환자를 골라 태그에 값을 넣는다.
///
/// 권한 문·감사 기록·문맥 읽기는 모두 <see cref="PatientPickerDialogView.Pick"/> 안에 있다 —
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
/// </summary>
private void OnPickPatient(object sender, RoutedEventArgs e)
{
var picked = PatientPickerDialogView.Pick(this, out var label);
if (picked is null)
{
return;
}
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다.
// 여기서 직접 UsePatient 도 부르면 두 번 그리고, 더 나쁘게는
// 세션과 창이 어긋나는 경로가 하나 더 생긴다.
PatientSession.Set(picked, label);
}
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
private void OnClearPatient(object sender, RoutedEventArgs e)
=> PatientSession.Set(null, string.Empty);
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;
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 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));
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
PatientText.Text = picked is null ? "환자 없음" : label;
PatientClearButton.IsEnabled = picked is not null;
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 +267,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);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -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,7 +317,9 @@
</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"/>
@@ -326,8 +340,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,7 +412,11 @@
<!-- ══ 치환 변수 카드 ══ --> <!-- ══ 치환 변수 카드 ══ -->
<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"/>
@@ -439,6 +462,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,25 @@ 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>
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 +138,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 +194,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 +525,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 +544,14 @@ 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;
Mouse.OverrideCursor = Cursors.Wait; Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result; SheetMe.Data.Stores.QueryTrialResult result;
@@ -495,26 +565,88 @@ public partial class QueryEditorWindow : Window
} }
TrialPane.Visibility = Visibility.Visible; TrialPane.Visibility = Visibility.Visible;
// 무엇으로 돌렸는지는 말해야 하지만 <b>환자를 두 번 적지 않는다</b> —
// 아래 버튼이 이미 "환자 해제 — 강한(0001381872) · 외래 …" 를 그대로 보여 준다.
// 같은 정보를 두 군데 적으면 요약이 두 줄로 늘어 결과 표를 밀어내고,
// 화면에 환자 이름이 필요 이상으로 여러 번 남는다.
// 환자가 있을 때 필요한 말은 이미 버튼이 하고 있으므로 여기서는 침묵한다.
var basis = variables is not null ? string.Empty : "변수 NULL — 구문만 검사";
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));
TrialGrid.ItemsSource = ToGridRows(result); TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed; TrialGrid.Visibility = result.Rows.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;
} }
} }
/// <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 가 열까지 만들어 주는 형태로 옮긴다.
/// ///
@@ -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,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
}
@@ -105,7 +105,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 +127,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