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>
TagValue Resolve(string tag);
}
/// <summary>
/// <c>DataTableField</c> 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
///
/// <b>태그와 다른 관이다.</b> 태그(<c>DataInterfaceTag</c>)는 함수 하나가 값을 만들지만,
/// 이쪽은 서식이 들고 있는 SQL 을 <b>실행</b>해서 나온 표에서 행·컬럼을 지목한다.
/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
/// </summary>
public interface IDataFieldResolver
{
/// <summary>이 배선의 값 — 못 만들면 사유가 담긴다</summary>
TagValue Resolve(DataTableFieldSpec spec);
}
@@ -427,6 +427,9 @@ public static class LegacyTagCatalog
"OCM_주상병명_Refer",
"OCM_주상병명_영어",
"OCM_주상병명_영어_Refer",
// 카탈로그 전수 추출(2026-07-16) 이후 레거시에 추가된 함수 —
// bzDataInterface.vb:10429 실존을 확인하고 등재했다(이름 게이트가 잡았다)
"OCM_주상병명_주상병코드",
"OCM_주상병일자",
"OCM_주상병일자_Refer",
"OCM_주상병코드",
@@ -14,28 +14,36 @@ namespace SheetMe.Core.Catalog;
/// (전체 검색 + 감사 로그), 그래서 <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>
public static class PatientPreviewPolicy
{
#region Methods
/// <summary>
/// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가.
/// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가 — <b>인증만</b> 요구한다.
///
/// 인증된 HIS 사용자여야 한다 — 미인증이면 감사 로그에 남길 주체가 없고,
/// 주체 없는 환자 조회는 그 자체로 남기면 안 되는 일이다.
/// 미인증이면 감사 로그에 남길 주체가 없고, 주체 없는 환자 조회는 남기면 안 되는 일이다.
/// 그것이 유일한 조건이다. 병원·부서 인자는 <b>일부러 받아 두고 쓰지 않는다</b> —
/// 병원별 규칙이 생기면 호출부를 고치지 않고 여기서 받을 수 있어야 하고,
/// 인자가 없으면 그때 호출부 전부를 다시 손대야 한다.
/// </summary>
public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod,
string hospitalStartDate)
=> uidCod.Length > 0
&& ModifyPolicy.CanModify(hospitalCode, departmentCode, uidCod, hospitalStartDate);
=> uidCod.Length > 0;
/// <summary>왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다</summary>
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
=> uidCod.Length == 0
? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요."
: ModifyPolicy.DenyReason(hospitalCode, uidCod, hospitalStartDate);
: string.Empty;
/// <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.DateTime,
["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.DateEnglish,
["ETC_현재일시_1_년"] = ClockFormat.Year,
@@ -65,6 +68,16 @@ public static class TagPreviewCatalog
["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName,
["ETC_로그인_사용자연락처"] = UserField.MobilePhone,
["ETC_로그인_원내연락처"] = UserField.OfficePhone,
// 세션 사용자 행(M_UidMst + 직종 조인)에서 나온다 — 환자 문맥이 필요 없다.
// 근무부서는 레거시가 개원일(HspStrDte>=20220701)로 DepOfcNam/DepKorNam 을 가르는데
// 개원일은 세션에 없어 빈 값 결정이라 DepKorNam 갈래로 간다(OCM_진료과와 같은 결정).
["ETC_로그인_의사면허번호"] = UserField.License,
["ETC_로그인_전문의번호"] = UserField.Specialist,
["ETC_로그인_직종"] = UserField.Job,
["ETC_로그인_근무부서"] = UserField.Department,
["ETC_로그인_근무부서_사용자명"] = UserField.DepartmentAndName,
// 이름은 "부서번호"지만 값은 부서 <b>전화번호</b>다(bzDataInterface.vb:17287-17313 — DepTelNum)
["ETC_접속유저_부서번호"] = UserField.DepPhone,
};
/// <summary>
@@ -81,6 +94,11 @@ public static class TagPreviewCatalog
// 환자를 골라도 영원히 안 나온다. 사유를 정직하게 하려고 넣는다.
["ETC_건강보험증번호_Refer"] = TagPreviewKind.Dead,
// 값은 UidNam 을 담고 조건은 UidCod 로 건다(bzDataInterface.vb:17262·17272) —
// 실무상 0건이라 레거시에서 늘 빈칸이던 태그다. 고치면 없던 값이 갑자기 채워지므로
// 출시 전 합의 전까지 레거시 동작(항상 빈 값)을 유지한다.
["ETC_로그인_직급"] = TagPreviewKind.Dead,
// 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다.
// 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다.
["퇴원약DT"] = TagPreviewKind.NotATag,
@@ -88,6 +106,25 @@ public static class TagPreviewCatalog
["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag,
["GetNurseEmrDT"] = TagPreviewKind.NotATag,
["GetNurseShtCod"] = TagPreviewKind.NotATag,
["GetNurseShtCod_GCRCH"] = TagPreviewKind.NotATag,
["GetNurseShtCod_GNBEDRO"] = TagPreviewKind.NotATag,
["GetNurseShtCod_SRH"] = TagPreviewKind.NotATag,
// 이름은 List 로 끝나지만 <b>값 한 줄</b>을 돌려준다(알러지는 CRLF 결합 문자열,
// 당뇨식칼로리는 최신 1건) — 아래 "List 끝 = 표" 규칙보다 먼저 잡아야
// 환자 해석기가 만든 실값이 "표를 돌려주는 태그" 사유에 가려지지 않는다.
["OCM_알러지_List"] = TagPreviewKind.PatientContext,
["OCM_당뇨식칼로리_List"] = TagPreviewKind.PatientContext,
// 반대로 이름이 규칙에 안 걸리는데 <b>DataTable</b> 을 돌려주는 콤보 채움용 4종
// (bzDataInterface.vb:16797-16890) — 미리보기 한 줄 값이 아니다.
["ETC_전체진료과_한글명"] = TagPreviewKind.Table,
["ETC_전체진료과_영문명"] = TagPreviewKind.Table,
["ETC_의사List_이름순"] = TagPreviewKind.Table,
["ETC_진료과별의사List_이름순"] = TagPreviewKind.Table,
// 이름이 "목록"으로 끝나 규칙(List/Datatable 끝)에 안 걸리는 DataTable 반환
// (bzDataInterface.vb:15414 — 수술실(OPR) 재직 간호사 이름 표, 콤보 채움용)
["ETC_수술간호사목록"] = TagPreviewKind.Table,
};
#endregion
@@ -174,6 +211,7 @@ public static class TagPreviewCatalog
// 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치
ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}",
ClockFormat.Year => date[..4],
ClockFormat.YearSuffixed => date[..4] + "년",
ClockFormat.Month => date[4..6],
ClockFormat.Day => date[6..8],
ClockFormat.Hour => minute12[8..10],
@@ -185,6 +223,16 @@ public static class TagPreviewCatalog
/// <summary>로그인 사용자 정보에서 이 태그의 값을 만든다</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office)
=> FromUser(tag, id, name, mobile, office, string.Empty, string.Empty, string.Empty, string.Empty);
/// <summary>9-인자판 — 부서 전화번호가 없던 시절의 호출부를 위해 위임</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office,
string license, string specialist, string job, string depName)
=> FromUser(tag, id, name, mobile, office, license, specialist, job, depName, string.Empty);
/// <summary>로그인 사용자 정보에서 — 면허·전문의·직종·부서·부서전화까지 받는 본판</summary>
public static string FromUser(string tag, string id, string name, string mobile, string office,
string license, string specialist, string job, string depName, string depPhone)
{
if (!UserTags.TryGetValue(tag, out var field))
{
@@ -194,15 +242,25 @@ public static class TagPreviewCatalog
{
UserField.Name => name,
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.OfficePhone => office,
UserField.License => license,
UserField.Specialist => specialist,
UserField.Job => job,
UserField.Department => depName,
// 레거시(:17101)는 NVL(DepOfcNam,DepKorNam)||'-'||UidNam — 개원일 미상 결정으로 한글명 고정.
// 레거시는 UidNam 으로 걸러 동명이인에서 남의 부서가 나올 수 있는데 여기는 세션 사용자 행이라 결정적이다.
UserField.DepartmentAndName => depName.Length > 0 || name.Length > 0 ? $"{depName}-{name}" : string.Empty,
UserField.DepPhone => depPhone,
_ => string.Empty,
};
}
private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, 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
}
+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>
public sealed record VisitSummary(
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>
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
@@ -97,26 +116,22 @@ public sealed class PatientVisitStore
using var connection = Open();
using var command = connection.CreateCommand();
command.BindByName = true;
// 처음에는 최종내원일시와 재원여부를 <b>파생표 두 개</b>로 붙였다.
// 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로,
// 조건에 맞는 환자가 몇 명이든 <b>내원 테이블 전체</b>를 집계했다 — 실측 5,025ms.
//
// 최종내원일시는 <b>화면에 쓰지도 않았다</b>(목록 열은 차트번호·성명·주민번호·휴대전화·비고).
// 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다.
//
// 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 —
// 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b> 따로 읽는다(아래 FillInpatient).
command.CommandText =
"SELECT * FROM ("
+ " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam,"
+ " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked,"
+ " NVL(PatMblPhn,' ') Mbl,"
+ " NVL(B.LastVisit,' ') LastVisit,"
+ " NVL(I.ComNum, 0) InP,"
+ " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon"
+ " FROM P_PatInf A"
// 최종 내원일시 — 레거시와 같은 서브쿼리(취소 내원 제외)
+ " 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"
+ " FROM P_PatInf"
+ $" WHERE {where}"
+ " ORDER BY PatNam, PatChtNum"
+ $") WHERE ROWNUM <= {max}";
@@ -131,12 +146,64 @@ public sealed class PatientVisitStore
reader.GetString(1).Trim(),
reader.GetString(2).Trim(),
reader.GetString(3).Trim(),
reader.GetString(4).Trim(),
reader.GetDecimal(5) > 0,
reader.GetString(6).Trim() == "Y",
reader.GetString(7).Trim() != "N"));
// 최종내원일시는 더 이상 읽지 않는다 — 화면에 없는 값이었고 집계가 5초였다
string.Empty,
false,
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>
@@ -164,24 +231,30 @@ public sealed class PatientVisitStore
+ " SELECT A.ComNum,"
+ " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind,"
+ " 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"
+ " FROM P_ComInf A"
+ " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0"
+ " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0"
+ " LEFT JOIN (SELECT TRIM(DepCod) Cod, DepKorNam Nam,"
+ " ROW_NUMBER() OVER (PARTITION BY TRIM(DepCod) ORDER BY DepStrDte DESC) Rn"
+ " FROM M_DepMst) Dep ON Dep.Cod = TRIM(B.CodDepCod) AND Dep.Rn = 1"
+ " LEFT JOIN (SELECT TRIM(UidCod) Cod, UidNam Nam,"
+ " 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"
// 처음에는 마스터 셋을 ROW_NUMBER 파생표로 접었다. 기간 PK 중복은 해결되지만
// <b>마스터 전체를 정렬</b>한 뒤에야 조인되므로 내원이 한 건인 환자도 5초가 걸렸다(실측).
// 상관 스칼라로 바꾼다 — 내원 행마다 코드 하나를 인덱스로 찾는다.
// MAX(...) KEEP (DENSE_RANK FIRST ORDER BY 시작일 DESC) 가 "가장 최근 것 하나"를
// 정렬 없이 뽑아 주고, 결과는 파생표 방식과 같다(중복도 생기지 않는다).
+ " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt"
+ " 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"
+ $") WHERE ROWNUM <= {max}";
command.Parameters.Add(new OracleParameter("p", key + "%"));
command.Parameters.Add(new OracleParameter("c", key));
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)
{
if (uidCod.Length == 0)
{
return (string.Empty, string.Empty);
}
var valid = ReadPhoneRow(connection,
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid"
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UIDSTRDTE AND UIDENDDTE", uidCod);
return valid ?? ReadPhoneRow(connection,
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid", uidCod)
?? (string.Empty, string.Empty);
}
private static (string Mobile, string Office)? ReadPhoneRow(
OracleConnection connection, string sql, string uidCod)
{
using var command = connection.CreateCommand();
command.CommandText = "SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid";
command.CommandText = sql;
command.CommandTimeout = TimeoutSeconds;
command.Parameters.Add(new OracleParameter("uid", uidCod));
using var reader = command.ExecuteReader();
if (!reader.Read())
{
return (string.Empty, string.Empty);
return null;
}
return (reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1));
+33 -3
View File
@@ -9,7 +9,23 @@ namespace SheetMe.Data.Stores;
public sealed record HisUser(
string UidCod, string UidNam,
string DepCod, string DepNam,
string HspCod, string HspNam)
string HspCod, string HspNam,
/// <summary>의사 여부(UidDtrYon=Y) — OCM_User진료과 계열이 로그인 사용자 분기에 쓴다</summary>
string DoctorYn = "",
/// <summary>부서 공식 명칭(DepOfcNam) — 개원일 조건이 걸리는 병원에서 한글명 대신 쓴다</summary>
string DepOfficialName = "",
/// <summary>의사면허번호(UidLicNum) — ETC_로그인_의사면허번호</summary>
string LicenseNumber = "",
/// <summary>전문의번호(UidSpcLic) — ETC_로그인_전문의번호</summary>
string SpecialistNumber = "",
/// <summary>직종명(M_DtsMst JOBCOD 의 DtsCodNam) — ETC_로그인_직종</summary>
string JobName = "",
/// <summary>부서 전화번호(M_DepMst.DepTelNum) — ETC_접속유저_부서번호(이름과 달리 전화번호다)</summary>
string DepPhone = "",
/// <summary>영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래</summary>
string EnglishName = "",
/// <summary>세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호</summary>
string DetailSpecialistNumber = "")
{
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
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();
using var command = connection.CreateCommand();
command.BindByName = true;
// 레거시와 두 가지가 <b>의도적으로</b> 다르다(워크플로 검증이 짚었고, 받아들이기로 했다):
// · 유효기간 판정 일자 — 레거시는 로그인 시점(GetAdpDtm 앞 8자, clsCommonLib.vb:10622),
// 여기는 SYSDATE. 자정을 넘겨 계속 켜 둔 세션에서만 갈린다.
// · Trim — 레거시는 UidCod/UidNam 을 무가공으로 쓰고(bzDataInterface.vb:16713, 16720)
// 여기는 걷는다. CHAR 잔여 공백이 종이에 찍히는 쪽이 더 이상하다고 판단했다.
command.CommandText =
"SELECT u.UidCod, NVL(u.UidNam,' '), " +
" NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " +
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' ') " +
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " +
" NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " +
" NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " +
" NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' ') " +
"FROM M_UidMst u " +
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
"LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " +
// 직종 조인은 레거시 그대로 DtsDtlCod 만으로 건다(bzDataInterface.vb:17944 — DtsTblCod 조건이 없다)
"LEFT JOIN M_DtsMst j ON j.DtsDtlCod = 'JOBCOD' AND j.DtsCod = u.UidJobCod " +
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN j.DtsStrDte AND j.DtsExpDte " +
"WHERE u.UidCod = :u " +
"AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
@@ -79,7 +106,10 @@ public sealed class UserContextStore
return new HisUser(
reader.GetString(0).Trim(), reader.GetString(1).Trim(),
reader.GetString(2).Trim(), reader.GetString(3).Trim(),
reader.GetString(4).Trim(), reader.GetString(5).Trim());
reader.GetString(4).Trim(), reader.GetString(5).Trim(),
reader.GetString(6).Trim(), reader.GetString(7).Trim(),
reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(),
reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim());
}
#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(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary>
/// 이 사용자가 <b>실제 환자</b>로 미리보기를 할 수 있는가 — 인증만 확인한다.
///
/// 수정과 <b>다른</b> 문이다. 레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이고
/// TK_PREVIEW 에는 조건이 없다(frmSheetDesigner.vb:1030-1060).
/// 여기에 부서 문을 걸면 서식을 만든 사람이 자기 서식을 검증할 수 없다.
/// 안전장치는 막는 것이 아니라 <b>감사 로그</b>다.
/// </summary>
public static bool CanPreviewPatient() => PatientPreviewPolicy.CanPreviewPatient(
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
HospitalStartDate);
public static string PreviewPatientDenyReason() => PatientPreviewPolicy.DenyReason(
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
/// <summary>
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
@@ -47,6 +47,48 @@ internal static class ClearTypeCheck
var window = BuildProbe(out var samples, out var popups);
window.Show();
// ── 실제 창을 하나 잰다 ──
// 위 표본들은 <b>기법</b>이 듣는지를 잰다(합성 도형). 그것이 통과해도
// 실제 창에 기법을 <b>안 걸었으면</b> 글자는 계속 뭉개진다 — 실제로 그랬다.
// 쿼리 편집 창은 카드에 그림자 Effect 가 있어 그 안 글자가 전부 중간 서피스를 탄다.
// 카드 제목만 재던 것으로는 부족했다 — 제목은 켜졌는데 <b>SQL 본문</b>은 계속 꺼져 있었다.
// 사람이 실제로 들여다보는 표면을 다 재야 한다: 본문·변수 목록·결과 요약.
var real = OpenQueryEditorForClearType();
if (real is null)
{
Lines.Add("SKIP G~J(실제 창) — 창을 띄우지 못해 판정 불가");
}
else
{
foreach (var (label, name) in new[]
{
("G(실제 창) 카드 제목", "SQL 편집기"),
("H(실제 창) SQL 본문", "Highlight"),
("I(실제 창) 치환 변수 목록", "VariableList"),
})
{
var target = name == "SQL 편집기"
? FindTextBlock(real, name)
: real.FindName(name) as FrameworkElement;
if (target is { ActualWidth: > 0 })
{
samples.Add((label, target, true));
// ── 흐림의 <b>두 번째 축</b> ──
// 색 번짐이 있어도(=ClearType 켜짐) TextFormattingMode 가 Ideal 이면
// 글리프가 픽셀 격자에 안 맞아 11~14px 글자가 눌린 듯 흐려 보인다.
// 색 번짐을 세는 방식으로는 두 값이 구분되지 않는다 — 그래서 두 번 놓쳤다.
// 이건 픽셀이 아니라 <b>속성 값</b>으로 잠근다.
var mode = TextOptions.GetTextFormattingMode(target);
Check($"{label} → 화면 글자는 Display 로 그린다",
mode == TextFormattingMode.Display, $"실제 {mode}");
}
else
{
Lines.Add($"SKIP {label} — 대상을 찾지 못해 판정 불가");
}
}
}
// 창이 보이는 것과 합성기가 그려 낸 것은 다르다 — Loaded 직후 찍으면 빈 화면을 잰다.
// (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정)
var timer = new System.Windows.Threading.DispatcherTimer
@@ -84,6 +126,56 @@ internal static class ClearTypeCheck
return failures == 0 ? 0 : 1;
}
/// <summary>
/// 실제 쿼리 편집 창을 띄워 카드 제목 글자를 잴 대상으로 내준다.
///
/// 합성 표본이 아니라 <b>배포되는 창</b>을 재야 한다 — 기법이 듣는 것과
/// 그 기법을 이 창에 걸어 둔 것은 다른 일이고, 후자가 빠져 있었다.
/// 창은 화면 안(0,0)에 띄운다. BitBlt 는 화면에 실제로 그려진 픽셀만 읽으므로
/// 화면 밖(-10000)에 두면 아무것도 못 잰다.
/// </summary>
private static Window? OpenQueryEditorForClearType()
{
try
{
var editor = new Views.QueryEditorWindow("MDataTable1",
"select PatChtNum, PatNam from P_PatInf where ROWNUM <= 1")
{
Left = 0,
Top = 0,
WindowStartupLocation = WindowStartupLocation.Manual,
};
editor.Show();
editor.UpdateLayout();
return editor;
}
catch (Exception ex)
{
Lines.Add($"SKIP G~I(실제 창) — {ex.GetType().Name}: {ex.Message}");
return null;
}
}
/// <summary>이 글자를 담은 TextBlock 을 찾는다 — 없으면 null</summary>
private static FrameworkElement? FindTextBlock(DependencyObject root, string text)
{
if (root is System.Windows.Controls.TextBlock block
&& block.Text.Contains(text, StringComparison.Ordinal))
{
return block;
}
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
for (var i = 0; i < count; i++)
{
if (FindTextBlock(System.Windows.Media.VisualTreeHelper.GetChild(root, i), text)
is { } found)
{
return found;
}
}
return null;
}
/// <summary>대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다</summary>
private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples)
{
+726 -5
View File
@@ -13,6 +13,18 @@ namespace SheetMe.Designer.Diagnostics;
/// </summary>
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
/// <summary>읽기 전용 일괄 왕복 스모크</summary>
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.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 가 잡히지 않으므로(실제 키 상태를 읽는다)
// 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다.
@@ -1848,15 +1893,18 @@ public static class DbSmoke
{
var store = new PatientVisitStore(ConfigService.Current.ConnectionString);
// 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다
// 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다.
// <b>시간을 함께 잰다.</b> "느리다"는 체감이고, 어디가 느린지는 숫자로만 안다.
foreach (var kind in new[]
{
PatientSearchKind.ChartNumber, PatientSearchKind.Name,
PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone,
})
{
var watch = System.Diagnostics.Stopwatch.StartNew();
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);
@@ -1874,8 +1922,24 @@ public static class DbSmoke
var capped = store.Search(PatientSearchKind.Name, term, max: 3);
Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건");
// 내원 목록 — 검색으로 찾은 첫 환자로
var people = store.Search(PatientSearchKind.Name, term, max: 1);
// 내원 목록 — <b>내원이 있는</b> 환자를 찾아 들어간다.
// 처음에는 첫 환자만 봤는데 그 환자에게 내원이 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)
{
lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가"
@@ -1883,13 +1947,591 @@ public static class DbSmoke
}
else
{
var visits = store.ListVisits(people[0].ChtNum);
lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)");
lines.Add($" 내원 {visits.Count}건");
Check("⑥ 내원 목록이 예외 없이 돈다", true);
// 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다
Check("⑦ 같은 내원이 중복되지 않는다",
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);
@@ -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)
{
var lines = new List<string>();
@@ -130,6 +130,12 @@ public static class DialogShots
// 'DB에서 열기' 대화상자를 지웠다. 그 자리를 좌측 서식 목록 샷이 대신한다 —
// 안 그러면 서식명 잘림을 잡을 자리가 게이트에서 통째로 사라진다.
("01-sheet-list", () => NewMainShell(1440, 920, leftTab: 0, sheets: SampleSheets())),
// 문서 없는 시작 화면 — '새 서식'을 없앤 뒤로는 이것이 <b>기본 상태</b>다.
// 여기가 대조군이다: 00-main-default 는 문서가 있어 인스펙터가 보여야 하고,
// 이 샷은 없어서 접혀야 한다. 접히지 않으면 DataContext 가 null 인 채로
// 안쪽 Visibility 바인딩이 전부 실패해 정렬 바·간격값이 남는다(WPF 는 실패를 Visible 로 떨군다).
("00d-main-empty", () => NewMainShell(1440, 920, leftTab: 0,
sheets: SampleSheets(), withDocument: false)),
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
("03-query-editor", () => NewQueryEditor(designer,
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
@@ -149,6 +155,12 @@ public static class DialogShots
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
("08-preview", () => new Views.PreviewWindow(designer)),
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
// 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
// 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
("09-inspector-label", () => HostInspector(designer, "Label1")),
@@ -220,7 +232,7 @@ public static class DialogShots
/// 빈 껍데기를 찍으면 잘림을 볼 수 없다.
/// </summary>
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
{
@@ -234,7 +246,10 @@ public static class DialogShots
{
return window;
}
shell.NewFileCommand?.Execute(null);
if (withDocument)
{
shell.AttachBlankForDiagnostics();
}
shell.SelectedLeftTabIndex = leftTab;
// 진단 모드는 Loaded 커맨드를 안 돌리므로 목록이 비어 있다 —
// 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다.
@@ -421,6 +436,33 @@ public static class DialogShots
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
};
/// <summary>
/// 데이터소스 표본 — 환자 없이 만든다. 그러면 치환이 전부 빈 값이 되어 SQL 이 깨지고,
/// 진단이 <b>실패 화면</b>을 찍는다. 그게 사람이 실제로 오래 보게 되는 화면이다.
/// DB 접속 여부와 무관하게 같은 그림이 나온다(치환 단계에서 이미 실패한다).
/// </summary>
private static Services.MDataTableRunner SampleRunner()
{
var document = new SheetMe.Core.Models.FormDocument();
var page = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
document.Pages.Add(page);
foreach (var (id, query) in new[]
{
("MDataTable1", "SELECT * FROM P_ComInf WHERE ComNum = <<bzPatientInfo.ComNum>>"),
("MDataTable2", "SELECT 1 FROM DUAL"),
})
{
var source = new SheetMe.Core.Models.ControlElement
{
Type = "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()
{
new DesignVersionInfo(52443, "202608121030", "011825", false),
+381 -5
View File
@@ -60,6 +60,43 @@ public static class EditSmoke
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)
{
var lines = new List<string>();
@@ -407,6 +444,37 @@ public static class EditSmoke
$"요소 {livePages} → {liveAfterAdd} (안 늘면 생성자에서 한 번만 그린 것)");
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_이름 이 종이에 그렇게 인쇄됐다.
// 해석기를 붙이면 값이 나오는 것은 값으로, 못 나오는 것은 <b>사유</b>로 바뀌어야 한다
@@ -444,15 +512,323 @@ public static class EditSmoke
&& !noResolver.Contains("[", StringComparison.Ordinal),
noResolver);
// 값이 나오는 태그는 값으로, 안 나오는 태그는 사유
// 값이 나오는 태그는 값으로, 안 나오는 태그는 <b>빈칸</b>으
// (2026-08-20 사용자 결정 — 출력물에 [태그 — 사유] 가 찍히면 안 된다.
// 태그 이름이 남아도 안 된다: 값이 나온 줄 알게 된다)
var withResolver = PaperText(new StubTagResolver());
Check("태그 값: 값이 나오는 태그는 값이 찍힌다",
withResolver.Contains("2026-08-18", StringComparison.Ordinal), withResolver);
Check("태그 값: 못 만드는 태그는 사유가 찍힌다",
withResolver.Contains("PAT_차트번호", StringComparison.Ordinal)
&& withResolver.Contains("환자", StringComparison.Ordinal)
&& withResolver.Contains("[", StringComparison.Ordinal),
Check("태그 값: 못 만드는 태그는 빈칸이 된다",
!withResolver.Contains("PAT_차트번호", StringComparison.Ordinal)
&& !withResolver.Contains("[", StringComparison.Ordinal),
withResolver);
// ── 환자 문맥을 붙였을 때 ──
// 여기가 이 단계의 전부다. 환자를 골랐는데 종이가 그대로면 고른 의미가 없고,
// 그건 환자 선택이 아예 없는 것보다 나쁘다("되는 줄 알았는데 안 된다").
// DB 없이 판정한다 — 문맥은 컬럼명 → 값 사전이므로 진짜 모양을 그대로 만들 수 있다.
static SheetMe.Data.Stores.PatientContext Fake(string chart, string name)
{
var patInf = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["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) 클릭 선택 — 컨트롤 중심(핸들 반경 밖)
@@ -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
/// <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();
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
// 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니
// MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고
// 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다.
// ShowDialog <b>전에</b> 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서
// 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다).
if (designer.Pages.Count > 0)
{
var first = designer.Pages[0];
dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize(
first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다
}
if (dialog.ShowDialog() != true)
{
return;
}
var document = BuildFixedDocument(designer);
var document = BuildFixedDocument(designer, tags, fields);
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
}
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
/// <summary>
/// 페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용).
///
/// <b>해석기를 반드시 받아 넘긴다.</b> 전에는 인자 없이 BuildPageVisual(page) 을 불러서
/// 태그·데이터소스 해석기가 <b>인쇄 경로에서만 사라졌다</b> — 미리보기에는 환자 값이
/// 찍히는데 그 창의 '인쇄' 버튼으로 뽑은 종이는 태그 이름 그대로였다.
/// 화면에서 확인하고 인쇄하면 다른 종이가 나오는 것 — 미리보기의 존재 이유를 무너뜨리는 결함이었다.
/// 실제 인쇄 PDF 와 미리보기 화면을 나란히 놓고서야 발견했다.
/// </summary>
public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
SheetMe.Core.Catalog.IDataFieldResolver? fields = null)
{
var document = new FixedDocument();
foreach (var page in designer.Pages)
@@ -40,7 +64,9 @@ public static class PrintService
Height = page.HeightDip,
Background = page.PaperBrush,
};
fixedPage.Children.Add(BuildPageVisual(page));
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields));
var pageContent = new PageContent();
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
@@ -59,7 +85,8 @@ public static class PrintService
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
/// </summary>
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
{
@@ -91,7 +118,7 @@ public static class PrintService
}
var presenter = new ContentPresenter
{
Content = PrintProjection(control, printFilter, tags),
Content = PrintProjection(control, printFilter, tags, fields),
Width = Math.Max(1, control.Width),
Height = Math.Max(1, control.Height),
};
@@ -121,11 +148,12 @@ public static class PrintService
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
/// </summary>
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)
{
return WithTagValue(control, tags);
return WithTagValue(control, tags, fields);
}
ContainerViewModel? copy = container switch
{
@@ -145,7 +173,7 @@ public static class PrintService
{
continue;
}
if (PrintProjection(child, printFilter, tags) is ControlViewModel projected)
if (PrintProjection(child, printFilter, tags, fields) is ControlViewModel projected)
{
copy.Children.Add(projected);
}
@@ -165,28 +193,75 @@ public static class PrintService
/// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다.
/// 타입이 같으니 템플릿이 그대로 잡히고, <b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b>
///
/// <b>못 만든 값은 감추지 않는다.</b> 사유를 대괄호로 감싸 그린다 —
/// 빈칸으로 두면 서식이 잘못된 줄 알고, 태그 이름을 그대로 두면 값이 나온 줄 안다.
/// 레거시는 실패한 컨트롤을 노란색으로 칠했다(같은 문제를 같은 이유로 다뤘다).
/// <b>못 만든 값은 빈칸이다</b>(2026-08-20 사용자 결정). 처음에는 사유를 대괄호로
/// 감싸 그렸는데("못 만든 것을 감추지 않는다"), 실사용 출력물에 [태그 — 사유] 가
/// 찍히면 안 되므로 값이 안 되는 태그는 종이·미리보기 모두 빈칸으로 바꾼다 —
/// 태그 이름을 그대로 두면 값이 나온 줄 알기 때문에 이름도 지운다.
/// 사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
/// </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;
}
var tag = control.Model.Props.GetText("DataInterfaceTag");
if (tag is not { Length: > 0 } || tag == "None")
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
{
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();
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);
}
/// <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
}
@@ -58,7 +58,10 @@ public sealed class SessionTagResolver : ITagValueResolver
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
? TagPreviewCatalog.FromClock(tag, ready.Minute12)
: 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
? new TagValue(true, value)
: new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다");
+23 -5
View File
@@ -28,6 +28,18 @@
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="FontSize" Value="13" />
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<!--
화면 글자는 Display 로 그린다. WPF 기본값인 Ideal 은 글리프를 픽셀 격자에 맞추지 않고
소수 위치에 놓아서, 11~14px UI 글자가 눌린 듯 흐려 보인다.
<b>ClearType 과는 다른 축</b>이다 — Ideal 이든 Display 든 색 번짐은 그대로 생기므로
색 번짐을 세는 진단으로는 이 흐림을 잡을 수 없었다(그래서 두 번 놓쳤다).
상속 속성이라 여기 한 번 걸면 TextBlock·TextBox·버튼·표 글자에 모두 닿는다.
<b>종이는 예외다.</b> PageView 의 용지 Grid 와 PrintService 의 캔버스가 각각
Ideal 을 명시하고 그 안의 글자는 그것을 상속한다 — 인쇄 지면 자릿수가 바뀌면
레거시와 다른 종이가 된다(진단 db-render 의 md5 가 그것을 잠그고 있다).
-->
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
</Style>
<!-- 순수 Window 인스턴스용(파생 클래스에는 위 키 스타일을 직접 걸어야 한다) -->
@@ -37,7 +49,12 @@
<Style TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
<Setter Property="FontFamily" Value="Malgun Gothic" />
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
<!--
TextFormattingMode 을 여기서 정하지 않는다. <b>스타일 세터는 상속값을 이긴다</b> —
여기에 값을 박으면 종이 안의 글자까지 그 값이 되어 용지 Grid 가 정한 Ideal 을 덮는다.
전에 Ideal 이 박혀 있어서 화면 UI 글자가 전부 흐렸다.
지금은 창(Display)과 종이(Ideal)가 각각 상속으로 정한다.
-->
</Style>
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
@@ -647,11 +664,12 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
<!--
탭 흐름만 둔다. 전에는 옆에 '새 서식 탭(+)' 버튼이 있었으나
서식은 반드시 목록에서 골라 연다 — 코드 없는 빈 문서는
DB 에 저장할 수 없어서, 만들 수 있게 두면 그린 뒤에 막힌다.
-->
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
<Button Content="" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
</StackPanel>
</ScrollViewer>
</Border>
@@ -206,7 +206,6 @@ internal sealed class MainViewModel : ViewModelBase
public MainViewModel()
{
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
NewFileCommand = new Command((sender, e) => OnNewFile());
OpenFileCommand = new Command((sender, e) => OnOpenFile());
// Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 —
// DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다.
@@ -258,9 +257,6 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>창 로드</summary>
public ICustomCommand? LoadedCommand { get; set; }
/// <summary>새 서식</summary>
public ICustomCommand? NewFileCommand { get; set; }
/// <summary>서식 XML 열기</summary>
public ICustomCommand? OpenFileCommand { get; set; }
@@ -497,8 +493,7 @@ internal sealed class MainViewModel : ViewModelBase
{
await LoadSheetListAsync();
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
// 기동 인자로 서식을 지정했으면 그것만 연다 — 레거시도 그렇다.
if (PendingSheetCode is { Length: > 0 } shtCod)
{
PendingSheetCode = null;
@@ -509,22 +504,22 @@ internal sealed class MainViewModel : ViewModelBase
}
}
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
// 아무것도 열지 않은 상태로 둔다. 전에는 빈 새 문서를 만들어 두었는데,
// 그건 <b>저장할 수 없는 문서</b>다 — 서식 코드가 없으니 DB 저장이 막히고,
// 사용자는 한참 그려 넣은 뒤에야 그 사실을 알게 된다.
// 서식은 반드시 목록에서 골라 연다(그래야 코드·권한·기존 디자인이 함께 온다).
SelectedLeftTabIndex = 0;
StatusText = "서식 목록에서 서식을 골라 여세요.";
}
private void OnNewFile()
{
try
{
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
StatusText = "새 서식 (720×856)";
}
catch (Exception ex)
{
DialogService.ShowError("새 서식 만들기", ex);
}
}
/// <summary>
/// 빈 문서를 붙인다 — <b>진단 전용</b>이다(--dialog-shots 가 화면을 찍으려면 문서가 필요하다).
///
/// UI 에는 '새 서식'이 없다. 코드 없는 문서는 DB 에 저장할 수 없고,
/// 그린 뒤에 그것을 알게 되는 것이 이 앱에서 가장 비싼 실수였다.
/// </summary>
internal void AttachBlankForDiagnostics()
=> AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
private void OnOpenFile()
{
@@ -983,9 +978,12 @@ internal sealed class MainViewModel : ViewModelBase
{
return;
}
// 미리보기를 안 거친 인쇄도 세션 환자를 따른다 — 해석기를 안 넘기면
// 태그 이름이 그대로 인쇄된다(미리보기 인쇄에서 실제로 그랬다).
var (tags, fields) = Services.PatientSession.ResolversFor(CurrentDesigner.Document);
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
? CurrentDesigner.Document.Title
: CurrentDesigner.Document.FormId);
: CurrentDesigner.Document.FormId, tags, fields);
}
catch (Exception ex)
{
@@ -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 의 표준 편집 키와 충돌한다.
-->
<Window.InputBindings>
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewFileCommand}"/>
<!-- Ctrl+O 는 코드비하인드에서 서식 목록으로 보낸다(파일 대화상자가 아니다) -->
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
@@ -164,7 +163,6 @@
edit-smoke 직렬화 왕복)이 그 경로를 쓴다. UI 진입점만 없앤 것이다.
-->
<MenuItem Header="파일(_F)">
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
<MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/>
<Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S"
@@ -296,6 +294,14 @@
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="다크/라이트 전환"
Click="OnThemeToggleClick"
Content="{Binding Source=sun, Converter={StaticResource IconName}, ConverterParameter=16}"/>
<!--
환자 선택 — 앱 전체에 적용된다. 전에는 미리보기 창 안에만 있어서
쿼리 편집처럼 환자 값이 필요한 다른 화면에서 고를 수가 없었다.
여기서 한 번 고르면 PatientSession 하나를 모든 화면이 본다.
고른 상태는 <b>아이콘 색</b>으로 알린다 — 늘 보이는 자리라 글자를 둘 폭이 없다.
-->
<Button x:Name="PatientIconButton" Style="{StaticResource Subtle}"
Width="34" Height="28" Padding="0" Click="OnPickPatientClick"/>
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="DB에 저장"
Command="{Binding SaveToDbCommand}"
Content="{Binding Source=save, Converter={StaticResource IconName}, ConverterParameter=16}"/>
@@ -752,9 +758,8 @@
HorizontalAlignment="Center" Margin="0,16,0,6"/>
<TextBlock Foreground="{DynamicResource B.Muted}" FontSize="12.5" TextAlignment="Center"
TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0,0,0,20"
Text="새 서식을 만들거나, 왼쪽 서식 목록에서 기존 서식을 두 번 눌러 엽니다."/>
Text="왼쪽 서식 목록에서 서식을 두 번 눌러 엽니다. (F4 또는 Ctrl+O)"/>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
<Button Content="새 서식" Command="{Binding NewFileCommand}" Padding="16,7" Margin="0,0,8,0"/>
<Button Content="서식 목록에서 열기" Click="OnOpenFromListClick" Padding="16,7"
IsEnabled="{Binding CanUseDb}"/>
</StackPanel>
@@ -771,7 +776,17 @@
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
</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>
</Border>
</Grid>
@@ -14,6 +14,12 @@ public partial class MainView : Window
public MainView()
{
InitializeComponent();
// 환자는 앱 전체가 하나를 공유한다 — 어느 화면에서 바뀌어도 이 아이콘이 따라와야 한다.
// 창이 살아 있는 동안만 듣는다(정적 이벤트라 떼지 않으면 창이 회수되지 않는다).
RefreshPatientIcon();
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => PatientSession.Changed -= OnPatientSessionChanged;
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
@@ -73,6 +79,69 @@ public partial class MainView : Window
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
}
/// <summary>
/// 환자 선택 — 여기서 고르면 <b>앱 전체</b>가 그 환자를 본다.
///
/// 안 골랐으면 바로 선택 창을 연다. 이미 골랐으면 <b>메뉴</b>를 띄운다 —
/// 늘 보이는 아이콘이라 "누르면 해제"로 두면 실수로 지운다.
/// 바꾸기·해제를 글자로 보여 주고 고르게 하는 쪽이 예측 가능하다.
/// </summary>
private void OnPickPatientClick(object sender, RoutedEventArgs e)
{
if (!PatientSession.HasPatient)
{
Pick();
return;
}
var menu = new System.Windows.Controls.ContextMenu
{
PlacementTarget = PatientIconButton,
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
};
var header = new System.Windows.Controls.MenuItem
{
Header = PatientSession.Label,
IsEnabled = false,
};
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
change.Click += (_, _) => Pick();
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
menu.Items.Add(header);
menu.Items.Add(new System.Windows.Controls.Separator());
menu.Items.Add(change);
menu.Items.Add(clear);
menu.IsOpen = true;
void Pick()
{
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
{
PatientSession.Set(picked, label);
}
}
}
/// <summary>
/// 아이콘이 지금 상태를 말한다 — 늘 보이는 자리라 글자를 둘 폭이 없어 <b>색</b>으로 알린다.
/// 고른 환자 이름은 툴팁에 둔다(제목줄에 환자 이름을 상시 노출하지 않는다).
/// </summary>
private void OnPatientSessionChanged(object? sender, EventArgs e) => RefreshPatientIcon();
private void RefreshPatientIcon()
{
if (PatientIconButton is null)
{
return;
}
var on = PatientSession.HasPatient;
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : null);
PatientIconButton.ToolTip = on
? $"환자: {PatientSession.Label}\n누르면 바꾸기·해제"
: "환자 선택 — 태그와 MDataTable 에 실제 값이 들어갑니다(앱 전체 적용)";
}
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
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"
ToolTip="끄면 화면에 보이는 대로 — 인쇄 출력을 끈 컨트롤도 보입니다(레거시 미리보기와 같음)"/>
<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"/>
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
<Button Content="" Padding="8,3" Click="OnZoomIn"/>
@@ -28,10 +28,26 @@ public partial class PreviewWindow : Window
private double zoom = 1.0;
/// <summary>
/// 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
/// </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
#region Constructors
@@ -42,18 +58,25 @@ public partial class PreviewWindow : Window
internal PreviewWindow(DesignerViewModel designer)
{
this.designer = designer;
tags = session;
InitializeComponent();
Activated += (_, _) => BuildPages();
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) =>
{
Unsubscribe();
PatientSession.Changed -= OnPatientSessionChanged;
if (ReferenceEquals(open, this))
{
open = null;
}
};
Subscribe();
BuildPages();
// 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
UsePatient(PatientSession.Current, PatientSession.Label);
}
#endregion
@@ -89,7 +112,9 @@ public partial class PreviewWindow : Window
Unsubscribe();
designer = next;
Subscribe();
BuildPages();
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
UsePatient(patient, PatientText.Text);
}
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
@@ -143,7 +168,7 @@ public partial class PreviewWindow : Window
BorderBrush = System.Windows.Media.Brushes.LightGray,
BorderThickness = new Thickness(1),
Margin = new Thickness(0, 0, 0, 20),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags),
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields),
Width = page.WidthDip,
Height = page.HeightDip,
};
@@ -156,6 +181,85 @@ public partial class PreviewWindow : Window
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>
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
@@ -163,9 +267,12 @@ public partial class PreviewWindow : Window
{
try
{
// 이 창이 종이에 그리는 것과 <b>같은 해석기</b>로 인쇄한다.
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
PrintService.Print(designer, designer.Document.Title.Length > 0
? designer.Document.Title
: designer.Document.FormId);
: designer.Document.FormId, tags, fields);
}
catch (Exception ex)
{
@@ -241,6 +241,14 @@
<TextBlock Text="검증 실행" VerticalAlignment="Center"/>
</StackPanel>
</Button>
<!--
환자 선택 — 검증 실행을 <b>실제 값</b>으로 돌리려면 여기서 고를 수 있어야 한다.
전에는 미리보기 창 안에만 있어서, 쿼리를 고치는 사람은 값 확인을 할 수 없었다
(변수가 NULL 로 들어가 늘 0행이었고, 그건 "안 된다"로 읽힌다).
고른 환자는 세션 하나를 공유하므로 미리보기와 같은 환자다.
-->
<Button x:Name="PatientButton" Padding="14,8" Margin="10,0,0,0" Click="OnPickPatient"
ToolTip="환자를 고르면 치환 변수에 실제 값이 들어가 값까지 확인됩니다"/>
<Button Content="확인" Padding="30,8" Margin="10,0,0,0"
Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/>
<Button Content="취소" Padding="26,8" Margin="10,0,0,0" IsCancel="True"/>
@@ -259,7 +267,11 @@
<!-- ══ SQL 편집기 카드 ══ -->
<Border Grid.Column="0" Style="{StaticResource Card}">
<DockPanel Margin="14,12,14,12">
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<TextBlock DockPanel.Dock="Top" Text="SQL 편집기" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/>
@@ -305,7 +317,9 @@
</DockPanel>
</Border>
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10">
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10"
Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
<Button DockPanel.Dock="Right" Content="닫기"
Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/>
@@ -326,8 +340,13 @@
본문 사이 구분선까지 함께 휘어 보인다. -->
<!-- 본문 면은 B.Surface 다. B.Input 을 쓰면 다크에서 홈통(B.Chip)과 같은 #262626 이라
홈통이 통째로 사라진다(양 토큰이 같은 값이다). -->
<!-- ClearTypeHint 를 여기에 건다. 카드 DockPanel 에만 걸어 봤더니 SQL 글자는
계속 뭉개졌다 — 본문은 이 아래 SqlHighlightLayer 가 그리고, 그 사이에
또 다른 중간 서피스가 있다(진단 cleartype 표본 I·J — ScrollViewer 도 경계다).
이 Border 는 불투명 B.Surface 를 갖고 Effect 경계 안쪽이라 조건이 맞는다. -->
<Border BorderThickness="1" CornerRadius="0"
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}">
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
@@ -393,7 +412,11 @@
<!-- ══ 치환 변수 카드 ══ -->
<Border x:Name="VariablePane" Grid.Column="2" Style="{StaticResource Card}">
<DockPanel Margin="14,12,14,12">
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled">
<TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
Margin="0,0,0,10"/>
@@ -439,6 +462,19 @@
HorizontalContentAlignment="Stretch"
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
AutomationProperties.Name="치환 변수 목록">
<!--
ListBox 는 템플릿 안에 ScrollViewer 를 품고 있고 그것이 중간 서피스다 —
카드에 건 힌트는 <b>바깥</b>이라 목록 글자에는 듣지 않는다(표본 I).
항목 패널을 불투명하게 만들어 힌트를 ScrollViewer <b>안쪽</b>에 둔다(표본 J).
StackPanel 이 아니라 VirtualizingStackPanel 이어야 한다 —
바꾸면 변수 120개가 전부 실체화된다.
-->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<VirtualizingStackPanel Background="{DynamicResource B.Surface}"
RenderOptions.ClearTypeHint="Enabled"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.ItemTemplate>
<DataTemplate>
<DockPanel Margin="0,3" ToolTip="{Binding Token}">
@@ -25,6 +25,25 @@ public partial class QueryEditorWindow : Window
#region Member Fields
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>
private readonly SheetMe.Data.Stores.OracleQueryWorkbench? workbench;
@@ -119,6 +138,10 @@ public partial class QueryEditorWindow : Window
TextCompositionManager.AddTextInputHandler(SqlBox, OnComposed);
RebuildBucketChips();
ApplyVariableFilter(string.Empty);
RefreshPatientButton();
// 제목줄이나 미리보기에서 환자를 바꿔도 이 버튼이 따라와야 한다
Services.PatientSession.Changed += OnPatientSessionChanged;
Closed += (_, _) => Services.PatientSession.Changed -= OnPatientSessionChanged;
var connection = Services.ConfigService.Current.ConnectionString;
workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(connection);
if (!workbench.CanUseDb)
@@ -171,6 +194,43 @@ public partial class QueryEditorWindow : Window
/// <summary>진단(--query-popup)에서 검증 실행을 부르는 통로 — 결과 표가 실제로 차는지 본다</summary>
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>
private void UpdateCompletion()
{
@@ -465,10 +525,18 @@ public partial class QueryEditorWindow : Window
#region Methods - Trial
/// <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>
private void OnTrial(object sender, RoutedEventArgs e)
{
@@ -476,12 +544,14 @@ public partial class QueryEditorWindow : Window
{
return;
}
var probe = SqlBox.Text;
foreach (var token in SqlTokenizer.VariablesIn(probe).Distinct(StringComparer.Ordinal))
{
// 런타임도 값이 없으면 빈 문자열로 치환한다(clsMDataTable.vb:67-71) — 같은 모양으로 맞춘다
probe = probe.Replace(token, string.Empty, StringComparison.Ordinal);
}
var variables = Services.PatientSession.Variables();
var converted = SheetMe.Core.Catalog.QuerySubstitution.Apply(
SqlBox.Text, variables ?? UnknownVariables.Instance,
// 환자가 있어도 자리표시는 준다 — 아직 안 옮긴 변수(Age·Sex)가 섞여 있으면
// 그것만 빈 값이 되어 같은 ORA-00936 이 난다.
emptyPlaceholder: "NULL");
var probe = converted.Sql;
lastTrialSql = probe;
Mouse.OverrideCursor = Cursors.Wait;
SheetMe.Data.Stores.QueryTrialResult result;
@@ -495,26 +565,88 @@ public partial class QueryEditorWindow : Window
}
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)
{
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource("B.Ink");
// 행이 0인 것과 실패는 다르다 — 구문은 맞는데 조건에 걸리는 데이터가 없을 뿐이다.
// 치환 변수를 빈 값으로 바꿔 실행하므로 조건이 빡빡하면 0행이 정상이다.
TrialSummary.Text = result.Rows.Count == 0
? result.Summary + " — 구문은 정상입니다. 치환 변수를 빈 값으로 실행하므로 조건에 걸리는 행이 없을 수 있습니다."
TrialSummary.Foreground = (System.Windows.Media.Brush)FindResource(
notes.Count > 0 ? "B.Danger" : "B.Ink");
var head = result.Rows.Count == 0
? result.Summary + (variables is null
? " — 구문은 정상입니다(변수가 NULL 이라 조건에 걸리는 행이 없습니다)."
: " — 구문은 정상이고, 이 환자에게 해당하는 행이 없습니다.")
: result.Summary;
// 빈 조각은 버린다 — 안 버리면 "성공 · · 경고" 처럼 가운뎃점이 떠 있는다
TrialSummary.Text = string.Join(" · ",
new[] { head, basis }.Concat(notes).Where(s => s.Length > 0));
TrialGrid.ItemsSource = ToGridRows(result);
TrialGrid.Visibility = result.Rows.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
}
else
{
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.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>
/// 결과를 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>
/// 환자 미리보기 접근 규칙 고정.
///
/// 이 표가 느슨해지면 서식을 만드는 사람 누구나 <b>운영 환자 기록</b>을 열 수 있다.
/// 레거시가 그 상태였다 — 인가코드가 소스에 평문이고, 검색 범위 무제한이고, 감사 0건이다.
/// 검색 범위는 레거시와 같게 두기로 했으므로 <b>감사와 권한</b>이 유일한 장치다.
/// 레거시는 인가코드가 소스에 평문이고, 검색 범위 무제한이고, 감사 0건이었다.
/// 검색 범위와 접근 범위는 레거시와 같게 두기로 했으므로 <b>감사 로그가 유일한 장치</b>다 —
/// 그래서 감사 한 줄의 모양(주체가 있는가, 이름이 새지 않는가)이 여기서 가장 중요한 판정이다.
/// </summary>
[TestClass]
public sealed class PatientPreviewPolicyTests
@@ -23,13 +23,30 @@ public sealed class PatientPreviewPolicyTests
}
[TestMethod]
public void _권한과_같은_축을_쓴()
public void _막지_않는()
{
// 환자 기록을 보는 일은 지금 쓰이는 서식을 제자리에서 고치는 일과 무게가 다르지 않다.
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("ABC", "EDPS", "011825", string.Empty));
Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("ABC", "NURS", "011825", string.Empty));
Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("HANKOOK", "SALO", "011825", string.Empty));
Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("XYZ", "EDPS", "011825", "20250501"));
// 처음에는 수정 권한(ModifyPolicy)을 그대로 갖다 썼다. 그건 레거시에 없는 제약이었다 —
// 레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이고 TK_PREVIEW 에는 문이 없다
// (frmSheetDesigner.vb:1030-1060). 서식을 만드는 사람이 자기 서식을 검증할 수 없으면
// 값이 틀린 서식이 그대로 운영에 올라간다.
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]
@@ -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));
// Data2Format_ENG 는 MM-DD-YYYY 로 재배치한다
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("14:32", TagPreviewCatalog.FromClock("ETC_현재시간", stamp));
}
@@ -125,8 +127,9 @@ public sealed class TagPreviewCatalogTests
Assert.AreEqual("U01.홍길동",
TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", "홍길동", "010", "1234"));
Assert.AreEqual("010", TagPreviewCatalog.FromUser("ETC_로그인_사용자연락처", "U01", "홍길동", "010", "1234"));
// 한쪽이 비면 점만 남은 값을 만들지 않는다
Assert.AreEqual(string.Empty,
// 레거시(bzDataInterface.vb:17930)는 빈 값 검사 없이 <b>항상</b> 결합한다 —
// 한쪽이 비어도 "U01." 이 그대로 나간다. 전에는 빈 값으로 뭉갰는데 그건 레거시와 달랐다.
Assert.AreEqual("U01.",
TagPreviewCatalog.FromUser("ETC_로그인_사용자ID_사용자명", "U01", string.Empty, "010", "1234"));
}
#endregion