Compare commits
112
Commits
893d81a40e
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5771a824d7 | ||
|
|
592275fa97 | ||
|
|
3e75bdc9b9 | ||
|
|
4b400b94e0 | ||
|
|
6a0331ec8a | ||
|
|
8c76a978a9 | ||
|
|
f6b456753c | ||
|
|
d83478619e | ||
|
|
e162d0fb6b | ||
|
|
73095c1415 | ||
|
|
f8531cb7e1 | ||
|
|
f4e239075d | ||
|
|
13edf3ecd9 | ||
|
|
33461a6c67 | ||
|
|
12e5ec6452 | ||
|
|
020595f8d4 | ||
|
|
fccd441193 | ||
|
|
be9d697a0d | ||
|
|
d45b674e29 | ||
|
|
8cc2d68ac1 | ||
|
|
d965b3f217 | ||
|
|
345b2e1da1 | ||
|
|
a0333791b6 | ||
|
|
2310971a10 | ||
|
|
31a05c205b | ||
|
|
86ecfff1a1 | ||
|
|
55c1a7fcf1 | ||
|
|
edb9cc65cf | ||
|
|
199a4142ee | ||
|
|
03fff0a3a4 | ||
|
|
02657b3893 | ||
|
|
8256b79800 | ||
|
|
abb08d0c6b | ||
|
|
d988a347f4 | ||
|
|
f06740ae51 | ||
|
|
7dcc4c2e48 | ||
|
|
4e6e086ece | ||
|
|
7bae9b9057 | ||
|
|
a33229b5e1 | ||
|
|
7f147a3012 | ||
|
|
f2f4cf81d3 | ||
|
|
c0ad782b6a | ||
|
|
975aff4211 | ||
|
|
ec47c98bdd | ||
|
|
4642439ffd | ||
|
|
adb3572e40 | ||
|
|
ba6da2252f | ||
|
|
4dad271e9c | ||
|
|
e065c5a645 | ||
|
|
94bdcc763f | ||
|
|
3e9d5b5cf3 | ||
|
|
26d9606d1e | ||
|
|
ef4f36419f | ||
|
|
460f6c4a91 | ||
|
|
0da3305a75 | ||
|
|
9278b39a6d | ||
|
|
e62e43d176 | ||
|
|
db969fc6b5 | ||
|
|
41e6961b0e | ||
|
|
dd96874c7c | ||
|
|
fd295744dd | ||
|
|
949db1aa18 | ||
|
|
5ec31cee41 | ||
|
|
99b8aaa9c2 | ||
|
|
5bf2cf034d | ||
|
|
942780ae97 | ||
|
|
3e9f50ee52 | ||
|
|
d6d594490c | ||
|
|
d63f6f6bc0 | ||
|
|
510663c5fc | ||
|
|
222d1987d5 | ||
|
|
4bdd583adb | ||
|
|
34c53e3d69 | ||
|
|
128aedc34f | ||
|
|
5ecc7b514a | ||
|
|
1baf805f02 | ||
|
|
bbbc7f5a08 | ||
|
|
52f1e30437 | ||
|
|
8964c662f3 | ||
|
|
3ef1937902 | ||
|
|
9bff3dd5f6 | ||
|
|
f50a7cff4b | ||
|
|
88dab2edcf | ||
|
|
be9889ed4e | ||
|
|
1f501df5f7 | ||
|
|
e9d8f7356e | ||
|
|
5072acb518 | ||
|
|
26e93cae94 | ||
|
|
a67b6ee4b8 | ||
|
|
9e3cd088e2 | ||
|
|
dbb3deeda3 | ||
|
|
3af7b3a071 | ||
|
|
ae2f8f2f61 | ||
|
|
a386c5504b | ||
|
|
2f5c9bf82d | ||
|
|
6d06f0a4c8 | ||
|
|
9b2ae0ea67 | ||
|
|
a869e5a5bc | ||
|
|
d72ab3ce1e | ||
|
|
7e38ba654a | ||
|
|
d6052610b9 | ||
|
|
1b49f07dd9 | ||
|
|
d55ca39e6a | ||
|
|
4150ebe1d7 | ||
|
|
862657cf2f | ||
|
|
280594362a | ||
|
|
b0d2e9b470 | ||
|
|
7e0e201acb | ||
|
|
836a3e843a | ||
|
|
b2bdc7fa41 | ||
|
|
6ae05b73cd | ||
|
|
d9c1eaa293 |
@@ -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` |
|
||||
@@ -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. 나이는 별도 작업.
|
||||
@@ -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> 첫 사례이므로 코드 주석과 여기에 근거를 남긴다.
|
||||
@@ -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 |
|
||||
@@ -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 | - | - |
|
||||
@@ -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 는 퇴원오더가 없는 재… |
|
||||
@@ -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)를 그대로 쓸 수 있다."
|
||||
}
|
||||
]
|
||||
@@ -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,327 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 여러 컨트롤을 함께 골랐을 때 <b>무엇을 한 번에 바꿔도 되는가</b> — 판정 한 곳.
|
||||
///
|
||||
/// <b>왜 순수 함수 한 곳인가.</b> 차단 근거의 대부분이 키 이름이 아니라 <b>대상 집합</b>에 달려 있다
|
||||
/// (타입이 섞였는가, 같은 패널의 라디오가 둘인가, 값의 형태가 대상마다 다른가).
|
||||
/// 이 판정을 UI 조건문으로 흩어 놓으면 큐레이션 경로에서 막은 것이 고급(원문) 경로로 그대로 새고,
|
||||
/// 어느 쪽이 진짜 규칙인지 아무도 모르게 된다. 여기 모아 두면 DB 없이 검사할 수 있고,
|
||||
/// 실사용 서식에서 반례가 0인 항목은 기대값을 0으로 고정할 수 있다.
|
||||
///
|
||||
/// <b>기준은 레거시가 열 수 있는 XML 이다.</b> 레거시 서식생성기는 타입에 없는 속성이나 변환 불가 값을
|
||||
/// 만나면 서식을 열 때마다 오류 모달을 띄운다. 그래서 "우리 화면에서 그럴듯한가"가 아니라
|
||||
/// "레거시가 이 XML 을 조용히 열 수 있는가"로 자른다.
|
||||
/// </summary>
|
||||
public static class BatchEditPolicy
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>
|
||||
/// 대상이 둘 이상이면 <b>언제나</b> 막는 키 — 값이 대상마다 달라야 의미가 있는 것들.
|
||||
///
|
||||
/// 같은 값을 여럿에 복사하는 순간 뜻이 무너지는 세 부류다.
|
||||
/// ① <b>유일해야 하는 식별자</b> — 이름, 서명 슬롯 번호, 탭 순서, 링크 대상.
|
||||
/// ② <b>남을 이름으로 가리키는 배선</b> — 액션 대상 컨트롤, 합산식. 같은 값을 넣으면
|
||||
/// 5번 문항을 누를 때 1번 문항이 초기화되는 식으로 <b>다른 곳</b>이 깨진다.
|
||||
/// ③ <b>되돌릴 수 없는 것</b> — 일련번호 발급은 서식을 여는 것만으로 DB 카운터를 올린다.
|
||||
/// Undo 스냅샷으로 덮이지 않는 유일한 부류다.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> AlwaysBlocked = new(StringComparer.Ordinal)
|
||||
{
|
||||
// ① 유일해야 하는 것
|
||||
"Name",
|
||||
"SignatureIndex", // 전자동의서가 이 값으로 서명 슬롯을 배정한다 — 같으면 보호자 칸에 환자 서명이 간다
|
||||
"DisplaySequence",
|
||||
"TabIndex",
|
||||
"LinkToNewDesign",
|
||||
"SMSWebControlName",
|
||||
// 저장 시 writer 가 Bounds/Id 에서 다시 만들어 덮는 키 — 고쳐도 저장하면 되돌아간다
|
||||
"Location",
|
||||
"Size",
|
||||
"LocationOnBase",
|
||||
// ② 남을 이름으로 가리키는 배선
|
||||
"DataActionTagControl",
|
||||
"GetDataActionTagControl",
|
||||
"GetDataActionTagControlChange",
|
||||
"AssociatedControl",
|
||||
"Formula",
|
||||
"FormulaResultCheck",
|
||||
"EventHandlerMappingTag",
|
||||
// ③ 되돌릴 수 없는 것 — 일련번호 발급
|
||||
"SequenceCode",
|
||||
"SequenceType",
|
||||
"SequenceNumber",
|
||||
"CheckSequenceAtSaving",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 타입이 섞였을 때만 막는 키 — 같은 타입끼리는 정상 작업이다.
|
||||
///
|
||||
/// <c>TextAlign</c> 이 대표다. 같은 이름이지만 어휘가 다르다 — 라벨·체크·라디오·버튼은
|
||||
/// <c>ContentAlignment</c>(TopLeft…BottomRight 9값), 텍스트박스·마스크박스는
|
||||
/// <c>HorizontalAlignment</c>(Left/Center/Right 3값)다. 섞어서 "MiddleCenter" 를 주면
|
||||
/// 텍스트박스 쪽에는 <b>레거시가 읽을 수 없는 값</b>이 들어간다.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> BlockedWhenTypesDiffer = new(StringComparer.Ordinal)
|
||||
{
|
||||
"TextAlign",
|
||||
// 태그는 타입마다 채우는 방식이 다르다(체크류는 Y/TRUE 판정, 글자류는 문자열) —
|
||||
// 섞어서 같은 태그를 주면 한쪽은 값이, 다른 쪽은 체크가 되어 뜻이 갈린다
|
||||
"DataInterfaceTag",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 고급(원문) 경로에서만 막는 키 — 큐레이션 경로는 <b>속성과 크기를 함께</b> 고치므로 안전하다.
|
||||
///
|
||||
/// 선의 방향·굵기가 그렇다. 인스펙터의 전용 행은 컨트롤마다 <c>LineGeometry</c> 를 태워
|
||||
/// 각 선의 길이를 보존한 채 축을 바꾼다. 반면 원문 텍스트 행은 속성만 쓰고 크기를 그대로 두어,
|
||||
/// XML 은 Vertical 인데 경계는 200×1 인 선이 된다 — EMR 은 그것을 1px 점으로 그린다.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> BlockedInRawOnly = new(StringComparer.Ordinal)
|
||||
{
|
||||
"Orientation",
|
||||
"BorderWidth",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 타입마다 라벨이 갈리는 키의 <b>중립 라벨</b> — 섞였을 때 한쪽 타입의 말을 쓰지 않는다.
|
||||
/// 예를 들어 체크박스의 "기본 체크"를 라디오까지 포함한 선택에 그대로 쓰면 어색하다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string> NeutralLabels = new(StringComparer.Ordinal)
|
||||
{
|
||||
["Checked"] = "기본값",
|
||||
["Text"] = "텍스트",
|
||||
["Items"] = "항목(줄바꿈 구분)",
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Types
|
||||
/// <summary>일괄 편집 허용 여부와, 막았다면 사람이 읽을 이유</summary>
|
||||
public readonly record struct BatchVerdict(bool Allowed, string? Reason)
|
||||
{
|
||||
/// <summary>허용</summary>
|
||||
public static BatchVerdict Ok => new(true, null);
|
||||
|
||||
/// <summary>차단 — 이유는 인스펙터에 그대로 보인다</summary>
|
||||
public static BatchVerdict No(string reason) => new(false, reason);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 선택된 타입들이 <b>모두</b> 갖는 속성 — 이것이 "공통 속성"이다.
|
||||
///
|
||||
/// <b>키 이름만 같으면 안 된다.</b> 편집기 종류·선택지·기본값까지 같아야 한 칸으로 묶을 수 있다.
|
||||
/// 하나라도 다르면 그 칸의 값이 대상마다 다른 뜻이 되고, 사용자는 그 사실을 알 방법이 없다.
|
||||
/// · <c>TextAlign</c> — 선택지가 9값 대 3값이라 탈락(섞으면 한쪽에 없는 값이 들어간다).
|
||||
/// · <c>BorderStyle</c> — 기본값이 "Fixed3D" 대 "None" 이라 탈락. 기본값이 갈리면
|
||||
/// 키가 없는 대상이 <b>남의 기본값</b>으로 보이고, 갈렸는지 여부 판정부터 틀어진다.
|
||||
/// · <c>visible</c>/<c>Visible</c> — 라벨만 소문자다(레거시가 그림자 속성을 쓴다).
|
||||
/// 키 문자열이 다르므로 자연히 탈락한다. <b>별칭으로 묶지 않는다</b> — 묶으면 라벨에
|
||||
/// 대문자 키가 생겨 EMR 에서는 사라지는데 우리 미리보기는 계속 보여 준다.
|
||||
///
|
||||
/// 스키마를 모르는 타입(표·격자 등 descriptor 가 없는 것)이 하나라도 섞이면 <b>빈 집합</b>이다.
|
||||
/// 무엇이 공통인지 말할 근거가 없는데 추측해서 열면 그게 손상 경로가 된다.
|
||||
/// </summary>
|
||||
public static IReadOnlyList<PropertyDef> MergeCommonDefs(IReadOnlyList<string> types)
|
||||
{
|
||||
if (types.Count == 0)
|
||||
{
|
||||
return Array.Empty<PropertyDef>();
|
||||
}
|
||||
var descriptors = new List<ControlDescriptor>(types.Count);
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (ControlRegistry.Find(type) is not { } descriptor)
|
||||
{
|
||||
return Array.Empty<PropertyDef>();
|
||||
}
|
||||
descriptors.Add(descriptor);
|
||||
}
|
||||
if (descriptors.Count == 1)
|
||||
{
|
||||
return descriptors[0].Properties;
|
||||
}
|
||||
|
||||
var merged = new List<PropertyDef>();
|
||||
// 기준은 첫 타입의 선언 순서 — 인스펙터 행 순서가 실행마다 달라지면 안 된다
|
||||
foreach (var def in descriptors[0].Properties)
|
||||
{
|
||||
var common = true;
|
||||
var labelDiffers = false;
|
||||
for (var i = 1; i < descriptors.Count && common; i++)
|
||||
{
|
||||
var other = descriptors[i].Properties.FirstOrDefault(p => string.Equals(p.Key, def.Key, StringComparison.Ordinal));
|
||||
if (other is null || other.Editor != def.Editor
|
||||
|| !SameChoices(def.Choices, other.Choices)
|
||||
|| !string.Equals(def.Default, other.Default, StringComparison.Ordinal))
|
||||
{
|
||||
common = false;
|
||||
break;
|
||||
}
|
||||
labelDiffers |= !string.Equals(def.Label, other.Label, StringComparison.Ordinal);
|
||||
}
|
||||
if (!common)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
merged.Add(labelDiffers && NeutralLabels.TryGetValue(def.Key, out var neutral)
|
||||
? Relabel(def, neutral)
|
||||
: def);
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 어느 타입엔가 있지만 공통으로 올리지 못한 키의 수 — 인스펙터가 "함께 못 바꾸는 것이 있다"고 알린다.
|
||||
/// 조용히 빼면 사용자는 그 속성이 <b>존재하지 않는다</b>고 읽는다.
|
||||
/// </summary>
|
||||
public static int ExcludedCount(IReadOnlyList<string> types)
|
||||
{
|
||||
if (types.Count < 2)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
var all = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var type in types)
|
||||
{
|
||||
if (ControlRegistry.Find(type) is not { } descriptor)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
foreach (var def in descriptor.Properties)
|
||||
{
|
||||
all.Add(def.Key);
|
||||
}
|
||||
}
|
||||
foreach (var def in MergeCommonDefs(types))
|
||||
{
|
||||
all.Remove(def.Key);
|
||||
}
|
||||
return all.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 키를 <b>이 대상들에</b> 한 번에 써도 되는가.
|
||||
/// </summary>
|
||||
/// <param name="key">PropBag 키</param>
|
||||
/// <param name="targets">선택된 컨트롤 모델</param>
|
||||
/// <param name="viaAdvancedRaw">고급(원문 텍스트) 경로인가 — 전용 편집기의 보정을 타지 않는다</param>
|
||||
/// <param name="parentOf">컨트롤 → 직계 부모(최상위면 null). 라디오 배타 판정에 쓴다.</param>
|
||||
public static BatchVerdict IsBatchable(string key, IReadOnlyList<ControlElement> targets,
|
||||
bool viaAdvancedRaw = false, Func<ControlElement, ControlElement?>? parentOf = null)
|
||||
{
|
||||
// 대상이 하나면 단일 선택과 똑같이 둔다 — 이 기능이 기존 동작을 좁히면 안 된다
|
||||
if (targets.Count <= 1)
|
||||
{
|
||||
return BatchVerdict.Ok;
|
||||
}
|
||||
if (AlwaysBlocked.Contains(key))
|
||||
{
|
||||
return BatchVerdict.No("대상마다 값이 달라야 하는 속성입니다");
|
||||
}
|
||||
if (viaAdvancedRaw && BlockedInRawOnly.Contains(key))
|
||||
{
|
||||
return BatchVerdict.No("원문으로 바꾸면 크기가 따라오지 않습니다 — 전용 편집기를 쓰세요");
|
||||
}
|
||||
var types = targets.Select(t => t.Type).Distinct(StringComparer.Ordinal).Count();
|
||||
if (types > 1 && BlockedWhenTypesDiffer.Contains(key))
|
||||
{
|
||||
return BatchVerdict.No("타입마다 값의 뜻이 다릅니다 — 타입별로 따로 고르세요");
|
||||
}
|
||||
// 라디오의 기본 선택 — 같은 부모에 둘 이상이면 <b>한 문항의 답이 둘</b>이 된다.
|
||||
// 레거시 라디오는 GroupName 이 없어 배타를 직계 부모에 맡기는데, 로더가 자식 속성을
|
||||
// 부모에 붙이기 <b>전에</b> 대입하므로 형제가 꺼지지 않는다 — 저장도 되고 그대로 열린다.
|
||||
// 캔버스에는 아무 신호가 없고, 종이에는 ● 가 둘 찍힌다.
|
||||
if (string.Equals(key, "Checked", StringComparison.Ordinal) && HasRadioSiblings(targets, parentOf))
|
||||
{
|
||||
return BatchVerdict.No("같은 묶음의 라디오는 하나만 선택될 수 있습니다");
|
||||
}
|
||||
return BatchVerdict.Ok;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 대상 <b>전부</b>가 이 키를 갖고, 전부 문자열 형태인가 — 고급(원문) 행을 열어도 되는 조건.
|
||||
///
|
||||
/// 둘 다 필요하다.
|
||||
/// · <b>전부 갖고 있어야</b> 한다. 없던 대상에 키가 생기면 레거시 서식생성기가
|
||||
/// "이 타입에 없는 속성"이라며 열 때마다 모달을 띄운다.
|
||||
/// · <b>전부 문자열이어야</b> 한다. 이미지·중첩·참조 값은 텍스트로 바꾸는 순간 원문이 사라진다.
|
||||
/// <c>GetText</c> 가 문자열이 아닌 값에 null 을 돌려주므로 빈 값과 이미지가 둘 다 "" 로 접혀
|
||||
/// <b>갈렸다는 표시조차 나오지 않는다</b> — 경고 없이 base64 원문이 교체된다.
|
||||
/// </summary>
|
||||
public static bool AllShareTextShape(string key, IReadOnlyList<ControlElement> targets)
|
||||
{
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (!target.Props.Contains(key))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (target.Props.Get(key) is not (LegacyPropValue.TextValue or LegacyPropValue.NullValue))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>이 키를 가진 대상 수 — "3개 중 1개에만 있음" 안내용</summary>
|
||||
public static int CountHaving(string key, IReadOnlyList<ControlElement> targets)
|
||||
=> targets.Count(t => t.Props.Contains(key));
|
||||
|
||||
/// <summary>선택지 동일성 — 원소와 순서까지. 참조 비교로는 절대 판정할 수 없다.</summary>
|
||||
private static bool SameChoices(string[]? left, string[]? right)
|
||||
{
|
||||
if (left is null || right is null)
|
||||
{
|
||||
return left is null && right is null;
|
||||
}
|
||||
return left.SequenceEqual(right, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 라벨만 바꾼 복제 — <see cref="PropertyDef"/> 는 record 가 아니라 <c>with</c> 를 쓸 수 없다.
|
||||
/// 선택지 배열은 <b>복사하지 않고 참조만</b> 넘긴다(레지스트리의 공개 값이라 절대 수정하지 않는다).
|
||||
/// </summary>
|
||||
private static PropertyDef Relabel(PropertyDef def, string label) => new()
|
||||
{
|
||||
Key = def.Key,
|
||||
Label = label,
|
||||
Editor = def.Editor,
|
||||
Choices = def.Choices,
|
||||
Default = def.Default,
|
||||
};
|
||||
|
||||
/// <summary>같은 직계 부모를 공유하는 라디오가 둘 이상 들어 있는가</summary>
|
||||
private static bool HasRadioSiblings(IReadOnlyList<ControlElement> targets,
|
||||
Func<ControlElement, ControlElement?>? parentOf)
|
||||
{
|
||||
var seen = new List<(ControlElement? Parent, int Count)>();
|
||||
foreach (var target in targets)
|
||||
{
|
||||
if (!string.Equals(target.Type, "RadioButton", StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 부모를 알 수 없으면(맵 미제공) 전부 같은 묶음으로 본다 — 모르면 막는 쪽이다.
|
||||
// 여기서 틀리면 한 문항에 답이 둘 켜진 서식이 나가고, 그건 화면으로 확인할 수 없다.
|
||||
var parent = parentOf?.Invoke(target);
|
||||
var index = seen.FindIndex(entry => ReferenceEquals(entry.Parent, parent));
|
||||
if (index < 0)
|
||||
{
|
||||
seen.Add((parent, 1));
|
||||
continue;
|
||||
}
|
||||
if (seen[index].Count + 1 >= 2)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
seen[index] = (parent, seen[index].Count + 1);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -66,7 +66,9 @@ public static class ControlRegistry
|
||||
/// </summary>
|
||||
public static PropertyDef DataTableField => new()
|
||||
{
|
||||
Key = "DataTableField", Label = "데이터 필드", Editor = PropEditorKind.DataTableField,
|
||||
// 라벨에 '배선' 을 쓰지 않는다 — 전산실에서 배선은 랜선·전원이다.
|
||||
// '값 가져오기' 는 방향이 이름에 들어 있어 형제 속성(데이터 태그)과도 구별된다.
|
||||
Key = "DataTableField", Label = "값 가져오기", Editor = PropEditorKind.DataTableField,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
using System.Data;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 조회 결과(컬럼·행 문자열)에서 <see cref="DataTableFieldSpec"/> 이 가리키는 한 칸을 꺼낸다 —
|
||||
/// Rows 형은 행 번호로, Select 형은 <b>.NET DataTable.Select 를 그대로 호출</b>해서 고른다.
|
||||
///
|
||||
/// <b>필터 문법을 직접 파싱하지 않는다.</b> 레거시 배선의 Select 필터는 .NET
|
||||
/// <c>DataTable.Select(filterExpression)</c> 문법 그대로다 — 같은 엔진에 넘기면
|
||||
/// 문법 호환을 우리가 증명할 필요가 없다. 파서를 새로 쓰면 LIKE·괄호·이스케이프에서
|
||||
/// 조용히 갈라진다.
|
||||
///
|
||||
/// <b>알려진 한계</b>: 우리 결과는 전부 문자열 컬럼이라(레거시는 Oracle 어댑터가
|
||||
/// 실타입으로 채운다) 숫자 대소 비교 필터(예: <c>AMT > 100</c>)는 사전순이 된다.
|
||||
/// 운영 실측 필터는 대부분 동등 비교(<c>컬럼 = '값'</c>)라 실질 영향이 좁지만,
|
||||
/// 대소 비교 필터를 만나면 값이 다를 수 있다 — 그때 타입 추론을 붙인다.
|
||||
/// </summary>
|
||||
public static class DataTableFieldPicker
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>한 칸의 값 — 못 고르면 사유</summary>
|
||||
public static TagValue Pick(IReadOnlyList<string> columns, IReadOnlyList<string[]> rows,
|
||||
DataTableFieldSpec spec)
|
||||
{
|
||||
var columnIndex = -1;
|
||||
for (var i = 0; i < columns.Count; i++)
|
||||
{
|
||||
if (string.Equals(columns[i], spec.Field, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
columnIndex = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (columnIndex < 0)
|
||||
{
|
||||
return new TagValue(false, $"결과에 '{spec.Field}' 컬럼이 없습니다"
|
||||
+ $" (있는 컬럼: {string.Join(", ", columns.Take(8))})");
|
||||
}
|
||||
|
||||
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
|
||||
{
|
||||
using var table = new DataTable();
|
||||
foreach (var name in columns)
|
||||
{
|
||||
// SELECT * 조인은 컬럼명이 겹칠 수 있다 — Oracle 어댑터의 Fill 과 같은
|
||||
// 규칙(이름1, 이름2 …)으로 비껴 담는다. spec.Field 는 첫 것과 매칭된다.
|
||||
var unique = name;
|
||||
var suffix = 1;
|
||||
while (table.Columns.Contains(unique))
|
||||
{
|
||||
unique = name + suffix.ToString(System.Globalization.CultureInfo.InvariantCulture);
|
||||
suffix++;
|
||||
}
|
||||
table.Columns.Add(unique, typeof(string));
|
||||
}
|
||||
foreach (var row in rows)
|
||||
{
|
||||
table.Rows.Add(row.Select(object (c) => c ?? string.Empty).ToArray());
|
||||
}
|
||||
DataRow[] picked;
|
||||
try
|
||||
{
|
||||
picked = table.Select(spec.Filter);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 필터 문법이 깨졌다 — 레거시 런타임은 빈 catch 로 삼켜 빈칸이 됐다
|
||||
return new TagValue(false, $"Select 필터를 해석할 수 없습니다: {ex.Message}");
|
||||
}
|
||||
if (spec.RowIndex >= picked.Length)
|
||||
{
|
||||
return new TagValue(false, picked.Length == 0
|
||||
? $"필터({spec.Filter})에 맞는 행이 없습니다"
|
||||
: $"{spec.RowIndex}번 행이 없습니다(필터 결과 {picked.Length}행)");
|
||||
}
|
||||
var filtered = picked[spec.RowIndex][columnIndex]?.ToString() ?? string.Empty;
|
||||
return filtered.Length > 0
|
||||
? new TagValue(true, filtered)
|
||||
: new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
|
||||
}
|
||||
|
||||
if (spec.RowIndex >= rows.Count)
|
||||
{
|
||||
return new TagValue(false, rows.Count == 0
|
||||
? "조회 결과가 0행입니다"
|
||||
: $"{spec.RowIndex}번 행이 없습니다(결과 {rows.Count}행)");
|
||||
}
|
||||
var cell = rows[spec.RowIndex][columnIndex] ?? string.Empty;
|
||||
return cell.Length > 0
|
||||
? new TagValue(true, cell)
|
||||
: new TagValue(false, $"{spec.Field} 값이 비어 있습니다");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>태그 하나를 값으로 바꾼 결과</summary>
|
||||
/// <param name="Resolved">값을 만들었는가 — false 면 <paramref name="Text"/> 는 사유다</param>
|
||||
/// <param name="Text">값 또는 사유</param>
|
||||
public readonly record struct TagValue(bool Resolved, string Text)
|
||||
{
|
||||
public static readonly TagValue None = new(false, string.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 미리보기에서 데이터 태그를 값으로 바꾸는 주체.
|
||||
///
|
||||
/// <b>왜 인터페이스인가.</b> 지금 값을 만들 수 있는 것은 서버 시각·로그인 사용자 계열
|
||||
/// 16종(운영 사용 579건, 9.1%)뿐이다. 나머지 368종은 환자·내원 문맥이 있어야 한다.
|
||||
/// 그 문맥이 생기면 <b>같은 자리로</b> 더 강한 해석기가 들어와야 하므로,
|
||||
/// 값이 흐를 관을 지금 파 두고 해석기만 갈아 끼운다.
|
||||
///
|
||||
/// <b>못 만든 것을 감추지 않는다.</b> 레거시는 값을 못 가져온 컨트롤을 노란색으로 칠했다
|
||||
/// (<c>ucLoadSheetBase.vb:2300-2308</c>) — 그런 장치가 있다는 것 자체가 현장 실패가 흔하다는 방증이다.
|
||||
/// 조용히 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다. 그래서 사유를 함께 돌려준다.
|
||||
/// </summary>
|
||||
public interface ITagValueResolver
|
||||
{
|
||||
/// <summary>이 태그의 값 — 못 만들면 <see cref="TagValue.Resolved"/> 가 false 이고 사유가 담긴다</summary>
|
||||
TagValue Resolve(string tag);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <c>DataTableField</c> 배선의 값 — MDataTable 조회 결과에서 한 칸을 꺼낸다.
|
||||
///
|
||||
/// <b>태그와 다른 관이다.</b> 태그(<c>DataInterfaceTag</c>)는 함수 하나가 값을 만들지만,
|
||||
/// 이쪽은 서식이 들고 있는 SQL 을 <b>실행</b>해서 나온 표에서 행·컬럼을 지목한다.
|
||||
/// 그래서 해석기도 따로 둔다 — 하나로 합치면 "값이 왜 없나"의 답이 두 가지로 섞인다
|
||||
/// (태그를 안 옮겼다 / 쿼리가 0행이다 / 컬럼명이 틀렸다는 서로 다른 고칠 거리다).
|
||||
/// </summary>
|
||||
public interface IDataFieldResolver
|
||||
{
|
||||
/// <summary>이 배선의 값 — 못 만들면 사유가 담긴다</summary>
|
||||
TagValue Resolve(DataTableFieldSpec spec);
|
||||
}
|
||||
@@ -427,6 +427,9 @@ public static class LegacyTagCatalog
|
||||
"OCM_주상병명_Refer",
|
||||
"OCM_주상병명_영어",
|
||||
"OCM_주상병명_영어_Refer",
|
||||
// 카탈로그 전수 추출(2026-07-16) 이후 레거시에 추가된 함수 —
|
||||
// bzDataInterface.vb:10429 실존을 확인하고 등재했다(이름 게이트가 잡았다)
|
||||
"OCM_주상병명_주상병코드",
|
||||
"OCM_주상병일자",
|
||||
"OCM_주상병일자_Refer",
|
||||
"OCM_주상병코드",
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// '수정'(제자리 정정)을 누가 쓸 수 있는가 — 레거시 <c>TK_MODIFY</c> 가시성 규칙을 옮긴 것.
|
||||
///
|
||||
/// <b>왜 저장과 다른가.</b> 수정은 활성 <c>SdgKey</c> 를 그대로 두고 그 행의 디자인만 바꾼다.
|
||||
/// EMR 이 참조하던 키가 유지되므로 <b>되돌릴 표식이 남지 않는 쓰기</b>에 가깝고,
|
||||
/// <c>E_SctMst</c> 를 건드리지 않아 새 컨트롤은 매핑 행이 생기지 않는다.
|
||||
/// 레거시가 이 버튼만 전산실에 묶어 둔 이유가 그것이다
|
||||
/// (<c>frmSheetDesigner.vb:1029-1060</c>, 주석 <c>'[추가] 박성한 20150302 전산실만 사용가능</c>).
|
||||
/// 저장 버튼에는 이런 분기가 없다.
|
||||
///
|
||||
/// 규칙을 여기 순수 함수로 두는 이유: 병원 코드·부서 코드 조합이 세 갈래라 화면에서 즉석 판정하면
|
||||
/// 어느 갈래가 왜 막혔는지 확인할 방법이 없다. 여기라면 표로 고정할 수 있다.
|
||||
/// </summary>
|
||||
public static class ModifyPolicy
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>이 두 병원은 전산실 외에 총무·구매 부서도 쓴다(레거시 :1033-1038)</summary>
|
||||
private static readonly string[] WideHospitals = { "HANKOOK", "GNRH" };
|
||||
|
||||
private static readonly string[] WideDepartments = { "EDPS", "SALO", "SAVE" };
|
||||
|
||||
/// <summary>이 날짜 이후 개원한 병원은 사내 계정만(레거시 :1042-1048)</summary>
|
||||
private const string VendorOnlyFrom = "20250501";
|
||||
|
||||
private const string VendorUid = "MSYS";
|
||||
|
||||
private const string ItDepartment = "EDPS";
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 사용자가 '수정'을 쓸 수 있는가.
|
||||
/// <paramref name="hospitalStartDate"/> 는 <c>HspStrDte</c>(YYYYMMDD) — 모르면 빈 문자열.
|
||||
/// </summary>
|
||||
public static bool CanModify(string hospitalCode, string departmentCode, string uidCod,
|
||||
string hospitalStartDate)
|
||||
{
|
||||
if (Array.Exists(WideHospitals, h => string.Equals(h, hospitalCode, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return Array.Exists(WideDepartments,
|
||||
d => string.Equals(d, departmentCode, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
if (string.CompareOrdinal(hospitalStartDate, VendorOnlyFrom) >= 0)
|
||||
{
|
||||
return string.Equals(uidCod, VendorUid, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
return string.Equals(departmentCode, ItDepartment, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>왜 막혔는지 — 화면이 "권한이 없습니다"만 말하면 누구에게 물어야 할지 모른다</summary>
|
||||
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
|
||||
{
|
||||
if (Array.Exists(WideHospitals, h => string.Equals(h, hospitalCode, StringComparison.OrdinalIgnoreCase)))
|
||||
{
|
||||
return "이 병원에서는 전산실·총무·구매 부서만 수정할 수 있습니다.";
|
||||
}
|
||||
if (string.CompareOrdinal(hospitalStartDate, VendorOnlyFrom) >= 0)
|
||||
{
|
||||
return $"이 병원에서는 {VendorUid} 계정만 수정할 수 있습니다.";
|
||||
}
|
||||
return "전산실 소속만 수정할 수 있습니다.";
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 환자 데이터로 미리보기를 볼 수 있는 조건.
|
||||
///
|
||||
/// <b>레거시에서 옮기지 않는 것이 요점이다.</b> 레거시 서식생성기는
|
||||
/// · 폼이 뜰 때마다 인가코드를 InputBox 로 묻고 <b>통과값 3개가 소스에 평문</b>으로 박혀 있고
|
||||
/// (<c>frmSheetDesigner.vb:913</c>),
|
||||
/// · 환자를 검색조건 없이 <b>운영 전체</b>에서 찾을 수 있고(<c>:636</c>),
|
||||
/// · 누가 어느 환자를 열었는지 <b>남기지 않는다</b>(감사 0건).
|
||||
/// 그 셋을 그대로 옮기면 신규 앱이 최악을 물려받는다.
|
||||
///
|
||||
/// 여기서 정하는 것은 <b>누가 볼 수 있는가</b>뿐이다. 검색 범위는 레거시와 같게 두기로 했고
|
||||
/// (전체 검색 + 감사 로그), 그래서 <b>남기는 쪽</b>이 안전장치의 본체다 —
|
||||
/// 조회 자체를 막지 않는 대신 흔적을 남긴다.
|
||||
///
|
||||
/// <b>부서 문(전산실)을 걸지 않는다.</b> 처음에는 <see cref="ModifyPolicy"/> 를 그대로 갖다 썼는데
|
||||
/// 그건 레거시에 없는 제약이었다. 레거시가 부서로 가리는 툴은 <c>TK_MODIFY</c> 하나뿐이고
|
||||
/// (<c>frmSheetDesigner.vb:1030-1060</c>, MSYS 전용인 <c>TK_ExportXML/ImportXML</c> 은 계정 문),
|
||||
/// <c>TK_PREVIEW</c> 에는 어떤 가시성 조건도 걸리지 않는다 — 서식생성기를 열 수 있으면 쓸 수 있다.
|
||||
///
|
||||
/// 두 일의 무게가 다르기도 하다. 수정은 <b>지금 쓰이는 서식을 제자리에서 바꿔</b> 병원 전체에
|
||||
/// 즉시 영향을 준다. 미리보기는 이미 그 사람이 볼 수 있는 기록을 <b>읽기만</b> 한다.
|
||||
/// 여기에 부서 문을 걸면 서식을 만드는 사람이 자기 서식을 검증할 수 없게 되고,
|
||||
/// 검증을 못 하면 값이 틀린 서식이 그대로 운영에 올라간다 — 막아서 더 위험해지는 쪽이다.
|
||||
/// </summary>
|
||||
public static class PatientPreviewPolicy
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가 — <b>인증만</b> 요구한다.
|
||||
///
|
||||
/// 미인증이면 감사 로그에 남길 주체가 없고, 주체 없는 환자 조회는 남기면 안 되는 일이다.
|
||||
/// 그것이 유일한 조건이다. 병원·부서 인자는 <b>일부러 받아 두고 쓰지 않는다</b> —
|
||||
/// 병원별 규칙이 생기면 호출부를 고치지 않고 여기서 받을 수 있어야 하고,
|
||||
/// 인자가 없으면 그때 호출부 전부를 다시 손대야 한다.
|
||||
/// </summary>
|
||||
public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod,
|
||||
string hospitalStartDate)
|
||||
=> uidCod.Length > 0;
|
||||
|
||||
/// <summary>왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다</summary>
|
||||
public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
|
||||
=> uidCod.Length == 0
|
||||
? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요."
|
||||
: string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 감사 로그 한 줄 — 누가·언제·어느 내원을 열었는가.
|
||||
///
|
||||
/// 문구를 여기서 만드는 이유: 호출부마다 다르게 적으면 나중에 로그를 찾을 수 없다.
|
||||
/// 환자 이름은 넣지 않는다 — 내원번호로 되짚을 수 있고, 로그 파일에 이름을 흘릴 이유가 없다.
|
||||
/// </summary>
|
||||
public static string AuditLine(string uidCod, string comNum)
|
||||
=> $"[환자 미리보기] 사용자={uidCod} 내원={comNum}";
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 미리보기에서 눌러 본 체크·라디오 상태 — <b>문서를 건드리지 않는다</b>.
|
||||
///
|
||||
/// <b>왜 별도 상태인가.</b> 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
|
||||
/// 실제로 눌러 보며 확인한다. 우리 미리보기도 눌러 볼 수 있어야 하지만, 그 클릭이 디자인을
|
||||
/// 바꾸면 "확인하려고 눌렀는데 서식이 수정됨" 이 된다. 그래서 컨트롤 Id → 상태 사전으로만 들고
|
||||
/// 렌더 시점에 덮어쓴다. 미리보기를 닫으면 사라진다.
|
||||
///
|
||||
/// <b>배타 규칙은 직계 부모다.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
|
||||
/// <c>GroupName</c> 이 없고(RadioButton.vb:7-8) 배타 판정을 부모 컨테이너에 맡긴다.
|
||||
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 직계 자식이다 —
|
||||
/// 즉 <b>패널 하나가 문항 하나</b>다. 여기서도 형제는 직계 부모의 자식만 본다.
|
||||
/// </summary>
|
||||
public sealed class PreviewCheckState
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly Dictionary<string, bool> overrides = new(StringComparer.Ordinal);
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>눌러 본 것이 하나라도 있는가 — 렌더가 덮어쓸 필요가 있는지 판단용</summary>
|
||||
public bool HasAny => overrides.Count > 0;
|
||||
|
||||
/// <summary>덮어쓸 상태 — 눌러 본 적 없으면 null</summary>
|
||||
public IReadOnlyDictionary<string, bool> Overrides => overrides;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이 컨트롤이 지금 체크된 상태인가 — 눌러 본 값이 없으면 서식의 기본값</summary>
|
||||
public bool IsChecked(ControlElement element)
|
||||
=> overrides.TryGetValue(element.Id, out var state)
|
||||
? state
|
||||
: string.Equals(element.Props.GetText("Checked"), "True", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>미리보기에서 누를 수 있는 타입인가</summary>
|
||||
public static bool IsClickable(ControlElement element)
|
||||
=> element.Type is "CheckBox" or "RadioButton"
|
||||
// 레거시는 읽기전용(PreventEditing)이면 OnClick 자체를 무시한다
|
||||
// (RadioButton.vb:271-279) — 값 대입은 되지만 클릭은 안 먹는다.
|
||||
&& !string.Equals(element.Props.GetText("PreventEditing"), "True", StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// 눌렀다 — 체크박스는 반전, 라디오는 자기를 켜고 <b>직계 형제 라디오를 끈다</b>.
|
||||
/// </summary>
|
||||
/// <param name="target">누른 컨트롤</param>
|
||||
/// <param name="siblings">그 컨트롤의 <b>직계 부모</b>가 담은 컨트롤 전부(자기 포함)</param>
|
||||
public void Click(ControlElement target, IReadOnlyList<ControlElement> siblings)
|
||||
{
|
||||
if (!IsClickable(target))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (target.Type == "CheckBox")
|
||||
{
|
||||
overrides[target.Id] = !IsChecked(target);
|
||||
return;
|
||||
}
|
||||
// 라디오 — 이미 켜져 있어도 다시 켠 상태로 둔다(레거시도 체크를 풀지 않는다).
|
||||
// 끄는 것은 같은 부모의 다른 라디오를 누르는 것뿐이다.
|
||||
foreach (var sibling in siblings)
|
||||
{
|
||||
if (sibling.Type == "RadioButton" && !ReferenceEquals(sibling, target))
|
||||
{
|
||||
overrides[sibling.Id] = false;
|
||||
}
|
||||
}
|
||||
overrides[target.Id] = true;
|
||||
}
|
||||
|
||||
/// <summary>눌러 본 것을 전부 잊는다 — 환자를 바꾸거나 서식을 다시 열 때</summary>
|
||||
public void Clear() => overrides.Clear();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using SheetMe.Core.Models;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 이 컨트롤이 <b>인쇄·미리보기에 나오는가</b>.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 레거시 런타임은 인쇄에서 이렇게 걸러낸다 —
|
||||
/// <c>If Me.Visible = False OrElse mbPrintOutPut = False Then Return False</c>
|
||||
/// (그 사실이 <see cref="ControlRegistry"/> 주석에 인용까지 되어 있다).
|
||||
/// 그런데 우리 미리보기는 그 두 키를 <b>아무도 읽지 않았다</b>. 대신 디자이너 전용 플래그
|
||||
/// (<c>ControlElement.Hidden</c>, <c>[JsonIgnore]</c>, 주석에 "레거시 Visible 과 별개")만 걸렀다.
|
||||
/// 그래서 인스펙터에서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
|
||||
/// <b>저장 전 검증 수단이 거짓말을 하고 있었다.</b>
|
||||
///
|
||||
/// <b>키 이름이 타입마다 다르다.</b> 라벨만 소문자 <c>visible</c> 이다
|
||||
/// (Label.vb 가 <c>Shadows Property visible</c> 로 <c>Control.Visible</c> 을 가리고 직렬화기가 그림자를 쓴다).
|
||||
/// 운영에서 소문자 58,864건(99.7%) 대 대문자 196건이다.
|
||||
/// 그래서 <b>타입이 선언한 키</b>를 카탈로그에서 찾아 그것만 읽는다 —
|
||||
/// 둘 다 관용으로 읽으면, 대문자 Visible=False 인 라벨이 인쇄에서 빠진다.
|
||||
/// 레거시는 그 라벨을 그대로 인쇄하므로 그건 우리가 만드는 새 차이다.
|
||||
///
|
||||
/// <b>모르는 값은 인쇄한다.</b> 키가 없거나 해석할 수 없으면 나온다 —
|
||||
/// 내용을 조용히 빼는 것이 조용히 넣는 것보다 나쁘다(빠진 것은 아무도 못 본다).
|
||||
/// </summary>
|
||||
public static class PrintFilter
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>인쇄·미리보기에 나오는가</summary>
|
||||
public static bool IsPrinted(ControlElement control)
|
||||
=> IsOn(control, VisibleKeyOf(control.Type)) && IsOn(control, "PrintOutPut");
|
||||
|
||||
/// <summary>
|
||||
/// 이 타입이 실제로 쓰는 '표시' 키. 카탈로그가 정답을 갖고 있으므로 하드코딩하지 않는다 —
|
||||
/// 타입이 늘거나 키가 바뀌면 여기가 따라온다.
|
||||
/// </summary>
|
||||
public static string VisibleKeyOf(string type)
|
||||
{
|
||||
var descriptor = ControlRegistry.Find(type);
|
||||
if (descriptor is null)
|
||||
{
|
||||
return "Visible";
|
||||
}
|
||||
foreach (var property in descriptor.Properties)
|
||||
{
|
||||
if (string.Equals(property.Key, "visible", StringComparison.Ordinal)
|
||||
|| string.Equals(property.Key, "Visible", StringComparison.Ordinal))
|
||||
{
|
||||
return property.Key;
|
||||
}
|
||||
}
|
||||
return "Visible";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 레거시가 쓰는 불리언 표기는 WinForms TypeConverter 의 <c>"True"</c>/<c>"False"</c> 다.
|
||||
/// 운영 데이터에는 대소문자·공백이 섞여 있어 관용으로 읽는다.
|
||||
/// </summary>
|
||||
private static bool IsOn(ControlElement control, string key)
|
||||
{
|
||||
var raw = control.Props.GetText(key)?.Trim();
|
||||
if (raw is null || raw.Length == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
return !(raw.Equals("False", StringComparison.OrdinalIgnoreCase)
|
||||
|| raw.Equals("0", StringComparison.Ordinal)
|
||||
|| raw.Equals("No", StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 치환 변수의 값을 내주는 쪽 — 미리보기는 환자 문맥이, 시험 실행은 표본이 맡는다.
|
||||
/// </summary>
|
||||
public interface IQueryVariableSource
|
||||
{
|
||||
/// <summary>
|
||||
/// 스칼라 속성 값. <b>null 은 "이 이름을 모른다"</b>, 빈 문자열은 "안다, 값이 비었다"다.
|
||||
/// 둘은 SQL 에서 같게 처리되지만(레거시가 그렇다) 사람에게는 다르게 말해야 한다.
|
||||
/// </summary>
|
||||
string? Scalar(string className, string property);
|
||||
|
||||
/// <summary>DataRow 속성. null 은 그 행을 모른다는 뜻</summary>
|
||||
IReadOnlyDictionary<string, string>? Row(string className, string property);
|
||||
}
|
||||
|
||||
/// <summary>치환 결과 한 건 — 무엇이 무엇으로 바뀌었는가</summary>
|
||||
/// <param name="Token">원문 토큰(<c><<…>></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><<…>></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>'<<…>>'</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
|
||||
}
|
||||
@@ -0,0 +1,235 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>
|
||||
/// 주민번호 보정과 그로부터 나오는 값들 — 복호화 <b>뒤</b>에 붙는 순수 계산.
|
||||
///
|
||||
/// 복호화 자체는 DB 가 한다(<c>CRYPTO_AES256.DEC_AES</c>). 앱은 키를 갖지 않는다 —
|
||||
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||
///
|
||||
/// <b>순수 함수로 두는 이유.</b> 갈래가 병원·국적·생년월일에 걸쳐 있고 실DB 로는
|
||||
/// 한 갈래씩 확인할 수 없다. 그리고 이 값이 틀리면 종이에 <b>다른 사람의 번호</b>가 찍힌다.
|
||||
/// </summary>
|
||||
public static class ResidentNumber
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>주민번호에 기관기호가 들어가는 병원 — bzPatientInfo.vb:556</summary>
|
||||
private static readonly string[] InstitutionPrefixHospitals = { "SRCH", "JINJU" };
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 복호화된 주민번호를 실제로 쓸 형태로 — 레거시 <c>bzPatientInfo.vb:546-580</c>.
|
||||
///
|
||||
/// <b>보정 ①(기관기호).</b> SRCH·JINJU 는 앞 6자리를 <paramref name="birthDay"/> 의
|
||||
/// YYMMDD 로 갈아 끼운다.
|
||||
///
|
||||
/// 원본은 <c>If Not DateValidCheck(sResNum.Substring(1, 6))</c> 로 감싸 있지만
|
||||
/// <b>그 조건은 항상 참이다</b> — <c>DateValidCheck</c> 는 길이가 8·12·14·17 이 아니면
|
||||
/// 무조건 False 를 돌려주는데(clsCommonLib.vb DateValidCheck) 호출부는 6자리를 넘긴다.
|
||||
/// 그래서 조건문이 아무것도 걸러 내지 않는다.
|
||||
///
|
||||
/// 여기서 "고쳐서" 진짜 날짜 검사를 넣으면 <b>레거시와 다른 번호</b>가 나온다 —
|
||||
/// 앞자리가 유효한 날짜인 환자에서 갈린다. 이식은 동작을 옮기는 일이지 바로잡는 일이 아니다.
|
||||
/// 바로잡을지는 별도 결정 사항이고, 그때 이 주석이 근거가 된다.
|
||||
///
|
||||
/// <b>보정 ②(외국인).</b> 7번째 자리가 <c>0</c> 또는 <c>9</c> 이고 국적코드가 있으면
|
||||
/// 생년월일의 세기로 그 자리를 바꾼다 — 0 → 19세기대면 6, 아니면 8 / 9 → 5 또는 7.
|
||||
/// </summary>
|
||||
/// <param name="decrypted">복호화(또는 평문) 주민번호 13자리</param>
|
||||
/// <param name="birthDay">P_PatInf.PatBthDay — YYYYMMDD</param>
|
||||
/// <param name="nationCode">P_PatInf.PatNatCod — 비어 있으면 외국인 보정을 하지 않는다</param>
|
||||
/// <param name="hospitalCode">HspCfg.HspCod</param>
|
||||
public static string Normalize(string? decrypted, string? birthDay, string? nationCode,
|
||||
string? hospitalCode)
|
||||
{
|
||||
var value = Clean(decrypted);
|
||||
var birth = Clean(birthDay);
|
||||
if (value.Length < 13)
|
||||
{
|
||||
// 원본은 Substring 에서 예외가 나고 Try 가 삼킨다 — 손대지 않는 것이 같은 결과다
|
||||
return value;
|
||||
}
|
||||
|
||||
// ① 기관기호 — 조건이 항상 참이므로 조건 자체를 옮기지 않는다(위 설명)
|
||||
if (Array.Exists(InstitutionPrefixHospitals,
|
||||
h => string.Equals(h, Clean(hospitalCode), StringComparison.OrdinalIgnoreCase))
|
||||
&& birth.Length >= 8)
|
||||
{
|
||||
value = birth[2..8] + value[6..13];
|
||||
}
|
||||
|
||||
// ② 외국인 — 국적코드가 있어야 하고, 세기 판정에 생년월일이 필요하다
|
||||
var seventh = value[6];
|
||||
if ((seventh == '0' || seventh == '9') && Clean(nationCode).Length > 0 && birth.Length >= 2)
|
||||
{
|
||||
var nineteenth = birth[..2] == "19";
|
||||
var digit = seventh == '0'
|
||||
? (nineteenth ? '6' : '8')
|
||||
: (nineteenth ? '5' : '7');
|
||||
value = value[..6] + digit + value[7..13];
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 성별 — 7번째 자리 mod 2 (<c>clsCommonLib.vb:613-656</c> GetSexAge).
|
||||
/// 짝수면 F, 홀수면 M. 숫자가 아니면 빈 문자열 —
|
||||
/// 원본은 <c>CLong</c> 에서 예외가 나고 삼켜져 값이 비는 것과 같다.
|
||||
/// </summary>
|
||||
public static string SexOf(string? residentNumber)
|
||||
{
|
||||
var value = Clean(residentNumber);
|
||||
if (value.Length < 7 || !char.IsAsciiDigit(value[6]))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return (value[6] - '0') % 2 == 0 ? "F" : "M";
|
||||
}
|
||||
|
||||
/// <summary>한글 성별 — F 는 여, M 은 남(<c>bzDataInterface.vb:305-316</c>)</summary>
|
||||
public static string SexKoreanOf(string? residentNumber) => SexOf(residentNumber) switch
|
||||
{
|
||||
"F" => "여",
|
||||
"M" => "남",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 하이픈 형태 — <c>bzDataInterface.vb:91-102</c>.
|
||||
/// 구분자가 <b>" - "</b>(양쪽 공백)이다. 공백을 빼면 레거시 서식과 폭이 달라진다.
|
||||
/// </summary>
|
||||
public static string WithDash(string? residentNumber)
|
||||
{
|
||||
var value = Clean(residentNumber);
|
||||
return value.Length >= 13 ? $"{value[..6]} - {value[6..13]}" : value;
|
||||
}
|
||||
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
|
||||
/// <summary>
|
||||
/// 생년월일부터 오늘까지의 <b>개월수</b> — 레거시 <c>PAT_나이_개월수</c>
|
||||
/// (<c>DateDiff(Month, PatBthDay, SystemDate) - 1</c>).
|
||||
///
|
||||
/// VB 의 <c>DateDiff(Month, ...)</c> 는 날짜를 보지 않고 <b>월 경계만 센다</b>
|
||||
/// (연차×12 + 월차). 거기서 1을 뺀 값이 태그의 결과다.
|
||||
/// 일수로 환산하지 않는다 — 환산하면 레거시와 다른 수가 나온다.
|
||||
/// </summary>
|
||||
public static string MonthsSince(string? birthDay, string? todayYmd)
|
||||
{
|
||||
var birth = Clean(birthDay);
|
||||
var today = Clean(todayYmd);
|
||||
if (birth.Length < 6 || today.Length < 6
|
||||
|| !int.TryParse(birth[..4], out var by) || !int.TryParse(birth.Substring(4, 2), out var bm)
|
||||
|| !int.TryParse(today[..4], out var ty) || !int.TryParse(today.Substring(4, 2), out var tm))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return (((ty - by) * 12) + (tm - bm) - 1).ToString();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// "N개월 N일" — 레거시 <c>PAT_나이_00개월_00일</c>.
|
||||
///
|
||||
/// <b>달력이 아니라 30일로 나눈다</b>(<c>days \ 30</c>, <c>days Mod 30</c>).
|
||||
/// 실제 개월수와 다르지만 레거시가 그렇게 찍는다 — 달력으로 고치면 값이 달라진다.
|
||||
/// </summary>
|
||||
public static string MonthsDaysSince(string? birthDay, string? todayYmd)
|
||||
{
|
||||
var days = DaysSince(birthDay, todayYmd);
|
||||
return days is null ? string.Empty : $"{days / 30}개월 {days % 30}일";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 생일부터 기준일까지 총 일수 — 개월·일 분해 태그 4종의 공통 재료.
|
||||
/// 날짜를 못 읽으면 null(레거시는 예외가 전파돼 노란색이 되던 자리다).
|
||||
/// </summary>
|
||||
public static int? DaysSince(string? birthDay, string? todayYmd)
|
||||
{
|
||||
var birth = Clean(birthDay);
|
||||
var today = Clean(todayYmd);
|
||||
if (birth.Length < 8 || today.Length < 8
|
||||
|| !DateTime.TryParseExact(birth[..8], "yyyyMMdd", null,
|
||||
System.Globalization.DateTimeStyles.None, out var from)
|
||||
|| !DateTime.TryParseExact(today[..8], "yyyyMMdd", null,
|
||||
System.Globalization.DateTimeStyles.None, out var to))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return (int)(to - from).TotalDays;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 만 나이 — 레거시 <c>AgeCheck</c>(clsCommonLib.vb:665-974)의 이식.
|
||||
///
|
||||
/// <b>세기 접두 규칙</b>(주민번호 7번째 자리): 1·2→19xx, 3·4→20xx,
|
||||
/// 5·6(외국인)→ 출생 2자리가 20 미만이면 20xx, 아니면 19xx 에 <b>월일을 0101 로
|
||||
/// 강제</b>(행려·외국인은 앞 6자리가 생년월일이 아닐 수 있다는 레거시 판단),
|
||||
/// 7·8→ 서버 오늘의 연도 2자리와 비교해 19/20, 그 외(0·9)→18xx.
|
||||
/// REDCROSS(적십자)·BSYD·BSGH 의 병원 특례는 옮기지 않았다 — 전부 Case Else 갈래다.
|
||||
///
|
||||
/// <b>만 나이 규칙</b>: 기준년-출생년, 생일이 아직 안 지났으면 -1(생일 당일은 차감 없음 —
|
||||
/// 2024-09-25 이후 전 병원 규칙). 나이가 1 이하면 총 일수차가 <b>365 미만</b>일 때 0
|
||||
/// (고정 365 — 윤년 경계의 하루 오차까지 레거시 그대로).
|
||||
///
|
||||
/// 레거시는 잘못된 입력에서 오류창을 띄우고 0 을 돌려줬다 — 여기서는 조용히 0 이다.
|
||||
/// </summary>
|
||||
public static long AgeOf(string? residentNumber, string? toDate, string serverToday)
|
||||
{
|
||||
var res = Clean(residentNumber);
|
||||
if (res.Length < 13 || serverToday.Length < 8)
|
||||
{
|
||||
return 0; // 레거시도 빈 값은 0, 짧은 값은 예외→0 이었다
|
||||
}
|
||||
string birth8;
|
||||
switch (res[6])
|
||||
{
|
||||
case '1' or '2':
|
||||
birth8 = "19" + res[..6];
|
||||
break;
|
||||
case '3' or '4':
|
||||
birth8 = "20" + res[..6];
|
||||
break;
|
||||
case '5' or '6':
|
||||
if (!int.TryParse(res[..2], out var left2))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
birth8 = left2 < 20 ? "20" + res[..6] : "19" + res[..2] + "0101";
|
||||
break;
|
||||
case '7' or '8':
|
||||
if (!int.TryParse(res[..2], out var yy2)
|
||||
|| !int.TryParse(serverToday.Substring(2, 2), out var nowYy))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
birth8 = (nowYy < yy2 ? "19" : "20") + res[..6];
|
||||
break;
|
||||
default:
|
||||
birth8 = "18" + res[..6];
|
||||
break;
|
||||
}
|
||||
var to = Clean(toDate);
|
||||
if (to.Length < 8)
|
||||
{
|
||||
to = serverToday;
|
||||
}
|
||||
if (!int.TryParse(to[..4], out var toYear) || !int.TryParse(birth8[..4], out var birthYear)
|
||||
|| !int.TryParse(birth8.Substring(4, 4), out var birthMmdd)
|
||||
|| !int.TryParse(to.Substring(4, 4), out var toMmdd))
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
long age = toYear - birthYear;
|
||||
if (birthMmdd > toMmdd)
|
||||
{
|
||||
age -= 1;
|
||||
}
|
||||
if (age <= 1)
|
||||
{
|
||||
var days = DaysSince(birth8, to[..8]);
|
||||
return days is null ? 0 : days < 365 ? 0 : 1;
|
||||
}
|
||||
return age;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>이미지 경로를 어떻게 얻었는가 — 화면·진단이 사유를 정확히 말하기 위해 구분한다</summary>
|
||||
public enum SignatureImageOrigin
|
||||
{
|
||||
/// <summary>쓸 수 있는 파일이 없다</summary>
|
||||
None,
|
||||
|
||||
/// <summary>레거시 EMR 이 이미 내려받아 둔 캐시 파일</summary>
|
||||
LegacyCache,
|
||||
|
||||
/// <summary>파일서버의 공유 폴더(UNC)로 직접 읽었다</summary>
|
||||
Share,
|
||||
|
||||
/// <summary>DB 가 준 경로 자체가 이 단말에서 바로 열린다</summary>
|
||||
DirectPath,
|
||||
}
|
||||
|
||||
/// <summary>이미지 경로 판정 결과</summary>
|
||||
/// <param name="Origin">어디서 얻었는가</param>
|
||||
/// <param name="LocalPath">열 수 있는 파일의 전체 경로 — 없으면 빈 문자열</param>
|
||||
/// <param name="FileName">원격 경로의 파일명(사유 문구·캐시 조회용)</param>
|
||||
public readonly record struct SignatureImageResult(
|
||||
SignatureImageOrigin Origin, string LocalPath, string FileName);
|
||||
|
||||
/// <summary>
|
||||
/// DB 가 준 <b>원격 경로</b>에서 실제로 열 수 있는 <b>로컬 파일</b>을 정한다.
|
||||
///
|
||||
/// <b>왜 이 규칙인가.</b> 레거시는 원격 경로를 파일서버에서 내려받아 로컬 경로를 돌려준다
|
||||
/// (bzDataInterface.vb 싸인 17종 공통). 그 전송 계층은 이 병원 설정에서 ActiveX OCX
|
||||
/// (<c>AxBITSFTLib.AxbitSFT</c>, M_DtlMst 'EMR_FileTransfer' Type=1)라 .NET 으로 재현할 수 없다 —
|
||||
/// OCX 내부 프로토콜이 코드에 없고, 우리 배포는 서드파티·ActiveX 를 금지한다.
|
||||
///
|
||||
/// 그래서 <b>받지 않고 찾는다</b>: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
|
||||
/// (<c>{설치경로}\Tmp\DownLoadedFiles\Signature\{파일명}</c> — 레거시가 쓰는 바로 그 폴더),
|
||||
/// ②DB 경로 자체가 이 단말에서 열리는 경우(로컬 절대경로·UNC 공유). 둘 다 아니면 없음이다.
|
||||
///
|
||||
/// 이 판정을 순수 함수로 두는 이유는 DB·파일서버 없이 규칙을 검증하기 위해서다.
|
||||
/// </summary>
|
||||
public static class SignatureImagePath
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>레거시가 쓰는 캐시 폴더 — 설치 경로가 없으면 빈 문자열</summary>
|
||||
public static string CacheFolder(string? installPath)
|
||||
=> string.IsNullOrWhiteSpace(installPath)
|
||||
? string.Empty
|
||||
// 레거시는 설치 경로가 없어도 문자열을 그대로 이어 붙여 "\Tmp\..." 라는
|
||||
// 루트 상대경로를 만든다(clsCommonLib.vb:271-287). 그 결함은 옮기지 않는다.
|
||||
: System.IO.Path.Combine(installPath.Trim(), "Tmp", "DownLoadedFiles", "Signature");
|
||||
|
||||
/// <summary>
|
||||
/// 원격 경로 → 열 수 있는 로컬 파일.
|
||||
/// </summary>
|
||||
/// <param name="remotePath">DB 가 준 경로(M_UidMst.UidImgPth 등)</param>
|
||||
/// <param name="installPath">HIS 설치 경로(ServerInfo ini 의 Install\Path)</param>
|
||||
/// <param name="exists">파일 존재 판정 — 테스트가 갈아 끼운다</param>
|
||||
/// <param name="shareMap">
|
||||
/// 서버 경로 → UNC 공유 대응(<c>"D:\MsystechHIS=\\서버\MsystechHIS"</c> 형태).
|
||||
/// 접두가 <b>긴 것부터</b> 맞춰 본다 — 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다.
|
||||
/// </param>
|
||||
public static SignatureImageResult Resolve(string? remotePath, string? installPath,
|
||||
Func<string, bool>? exists = null, IReadOnlyList<string>? shareMap = null)
|
||||
{
|
||||
var remote = (remotePath ?? string.Empty).Trim();
|
||||
if (remote.Length == 0)
|
||||
{
|
||||
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, string.Empty);
|
||||
}
|
||||
// 레거시는 역슬래시 기준으로 파일명을 뗀다(경로 구분자가 서버 규약이라 Path 로 자르지 않는다)
|
||||
var fileName = remote.Split('\\', '/').Last();
|
||||
var probe = exists ?? System.IO.File.Exists;
|
||||
|
||||
var folder = CacheFolder(installPath);
|
||||
if (folder.Length > 0 && fileName.Length > 0)
|
||||
{
|
||||
var cached = System.IO.Path.Combine(folder, fileName);
|
||||
if (probe(cached))
|
||||
{
|
||||
return new SignatureImageResult(SignatureImageOrigin.LegacyCache, cached, fileName);
|
||||
}
|
||||
}
|
||||
// 캐시에 없으면 파일서버 공유(UNC)로 직접 읽는다 — 설정된 접두 대응을 쓴다.
|
||||
// 이 경로가 이 작업의 목적이다: OCX 전송을 재현하는 대신 같은 파일을 공유로 본다.
|
||||
if (MapToShare(remote, shareMap) is { Length: > 0 } shared && probe(shared))
|
||||
{
|
||||
return new SignatureImageResult(SignatureImageOrigin.Share, shared, fileName);
|
||||
}
|
||||
// 그래도 없으면 원격 경로가 이 단말에서 그대로 열리는지 본다
|
||||
// (서버에서 실행하거나 같은 구조로 마운트된 단말)
|
||||
if (probe(remote))
|
||||
{
|
||||
return new SignatureImageResult(SignatureImageOrigin.DirectPath, remote, fileName);
|
||||
}
|
||||
return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, fileName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서버 경로를 UNC 로 바꾼다 — 대응이 없으면 빈 문자열.
|
||||
/// 대소문자를 무시하고, 접두가 <b>긴 것부터</b> 맞춘다.
|
||||
/// </summary>
|
||||
public static string MapToShare(string? remotePath, IReadOnlyList<string>? shareMap)
|
||||
{
|
||||
var remote = (remotePath ?? string.Empty).Trim();
|
||||
if (remote.Length == 0 || shareMap is null || shareMap.Count == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var best = string.Empty;
|
||||
var bestLen = -1;
|
||||
foreach (var entry in shareMap)
|
||||
{
|
||||
var at = entry.IndexOf('=', StringComparison.Ordinal);
|
||||
if (at <= 0 || at == entry.Length - 1)
|
||||
{
|
||||
continue; // "서버접두=UNC접두" 가 아니면 무시한다(설정 오타를 값으로 바꾸지 않는다)
|
||||
}
|
||||
var from = entry[..at].Trim().TrimEnd('\\');
|
||||
var to = entry[(at + 1)..].Trim().TrimEnd('\\');
|
||||
if (from.Length == 0 || to.Length == 0 || from.Length <= bestLen)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (remote.StartsWith(from + "\\", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
best = to + remote[from.Length..];
|
||||
bestLen = from.Length;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>태그 값을 이 자리에서 만들 수 있는가 — 못 만든다면 왜</summary>
|
||||
public enum TagPreviewKind
|
||||
{
|
||||
/// <summary>서버 시각으로 만든다</summary>
|
||||
Clock,
|
||||
|
||||
/// <summary>로그인 사용자 정보로 만든다</summary>
|
||||
LoginUser,
|
||||
|
||||
/// <summary>환자·내원 문맥이 있어야 한다 — 서식생성기에는 없다</summary>
|
||||
PatientContext,
|
||||
|
||||
/// <summary>값이 아니라 목록(표)을 돌려준다 — 콤보를 채우는 용도</summary>
|
||||
Table,
|
||||
|
||||
/// <summary>파일서버 이미지 경로를 돌려준다 — 싸인·직인</summary>
|
||||
Image,
|
||||
|
||||
/// <summary>레거시가 항상 빈 값을 돌려준다</summary>
|
||||
Dead,
|
||||
|
||||
/// <summary>태그가 아니라 레거시 내부 함수 이름이다</summary>
|
||||
NotATag,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 태그의 값을 서식생성기에서 만들 수 있는지, 없다면 왜인지.
|
||||
///
|
||||
/// <b>왜 표로 박는가.</b> 판정 근거는 레거시 함수 본문이 환자·내원 객체를 참조하는지 여부다.
|
||||
/// 그건 태그 이름에도 접두어에도 컨트롤 타입에도 들어 있지 않다 —
|
||||
/// <c>ETC_현재일자</c> 와 <c>ETC_요양기관명칭_병원명</c> 은 같은 접두어인데 한쪽만 만들 수 있다.
|
||||
/// 이름 규칙으로 추정하면 절반만 맞는다. 그래서 실측 결과를 그대로 옮겨 적는다.
|
||||
///
|
||||
/// <b>값보다 사유가 본체다.</b> 실제로 값이 나오는 것은 384종 중 16종(4%)뿐이다.
|
||||
/// 그러나 나머지에 대해 "왜 안 나오는가"는 지금 어디서도 얻을 수 없는 정보다 —
|
||||
/// 실사용 3위인 <c>ETC_요양기관명칭_병원명</c>(303건)이 환자 내원을 타고 병원을 푼다는 것도,
|
||||
/// 표를 돌려주는 태그와 죽은 태그와 태그가 아닌 내부 함수가 목록에 섞여 있다는 것도.
|
||||
/// </summary>
|
||||
public static class TagPreviewCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>서버 시각으로 만드는 것 — 값은 포맷 지시자</summary>
|
||||
private static readonly Dictionary<string, ClockFormat> ClockTags = new(StringComparer.Ordinal)
|
||||
{
|
||||
["ETC_현재일자"] = ClockFormat.Date,
|
||||
["ETC_현재일시"] = ClockFormat.DateTime,
|
||||
["ETC_현재일시_MaskBox"] = ClockFormat.DateTimeMask,
|
||||
// "년" 접미가 붙는다(bzDataInterface.vb:16923 — Data2Format(...) + "년").
|
||||
// 처음에 접미 없는 Year 로 넣어 레거시와 다른 값("2026")이 나갔다 —
|
||||
// 형제 태그 ETC_현재일시_1_년(:16929)은 접미가 <b>없으므로</b> 둘을 갈라야 한다.
|
||||
["ETC_현재년도"] = ClockFormat.YearSuffixed,
|
||||
["ETC_현재시간"] = ClockFormat.Time,
|
||||
["ETC_현재일자_영문"] = ClockFormat.DateEnglish,
|
||||
["ETC_현재일시_1_년"] = ClockFormat.Year,
|
||||
["ETC_현재일시_2_월"] = ClockFormat.Month,
|
||||
["ETC_현재일시_3_일"] = ClockFormat.Day,
|
||||
["ETC_현재일시_4_몇시"] = ClockFormat.Hour,
|
||||
["ETC_현재일시_5_몇분"] = ClockFormat.Minute,
|
||||
};
|
||||
|
||||
/// <summary>로그인 사용자 정보로 만드는 것 — 값은 어느 항목인지</summary>
|
||||
private static readonly Dictionary<string, UserField> UserTags = new(StringComparer.Ordinal)
|
||||
{
|
||||
["ETC_로그인_사용자명"] = UserField.Name,
|
||||
["ETC_로그인_사용자ID"] = UserField.Id,
|
||||
["ETC_로그인_사용자ID_사용자명"] = UserField.IdAndName,
|
||||
["ETC_로그인_사용자연락처"] = UserField.MobilePhone,
|
||||
["ETC_로그인_원내연락처"] = UserField.OfficePhone,
|
||||
// 세션 사용자 행(M_UidMst + 직종 조인)에서 나온다 — 환자 문맥이 필요 없다.
|
||||
// 근무부서는 레거시가 개원일(HspStrDte>=20220701)로 DepOfcNam/DepKorNam 을 가르는데
|
||||
// 개원일은 세션에 없어 빈 값 결정이라 DepKorNam 갈래로 간다(OCM_진료과와 같은 결정).
|
||||
["ETC_로그인_의사면허번호"] = UserField.License,
|
||||
["ETC_로그인_전문의번호"] = UserField.Specialist,
|
||||
["ETC_로그인_직종"] = UserField.Job,
|
||||
["ETC_로그인_근무부서"] = UserField.Department,
|
||||
["ETC_로그인_근무부서_사용자명"] = UserField.DepartmentAndName,
|
||||
// 이름은 "부서번호"지만 값은 부서 <b>전화번호</b>다(bzDataInterface.vb:17287-17313 — DepTelNum)
|
||||
["ETC_접속유저_부서번호"] = UserField.DepPhone,
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 못 만드는 것 중 <b>환자 문맥 말고 다른 이유</b>인 것들.
|
||||
/// 여기 없는 것은 전부 <see cref="TagPreviewKind.PatientContext"/> 다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, TagPreviewKind> Exceptions = new(StringComparer.Ordinal)
|
||||
{
|
||||
// 레거시가 항상 빈 문자열을 돌려준다(본문이 Return "")
|
||||
["OCM_수술일자_Refer"] = TagPreviewKind.Dead,
|
||||
|
||||
// 본문 34줄이 전부 주석 처리되고 Return "" 만 남았다(bzDataInterface.vb:15352).
|
||||
// 운영에서 2건 쓰이는데, 이 표에 없으면 화면이 "환자 정보가 있으면 나온다"고 말한다 —
|
||||
// 환자를 골라도 영원히 안 나온다. 사유를 정직하게 하려고 넣는다.
|
||||
["ETC_건강보험증번호_Refer"] = TagPreviewKind.Dead,
|
||||
|
||||
// 값은 UidNam 을 담고 조건은 UidCod 로 건다(bzDataInterface.vb:17262·17272) —
|
||||
// 실무상 0건이라 레거시에서 늘 빈칸이던 태그다. 고치면 없던 값이 갑자기 채워지므로
|
||||
// 출시 전 합의 전까지 레거시 동작(항상 빈 값)을 유지한다.
|
||||
["ETC_로그인_직급"] = TagPreviewKind.Dead,
|
||||
|
||||
// 태그가 아니라 내부 헬퍼 — 우리 카탈로그가 Public Function 을 전수 추출해 섞여 들어왔다.
|
||||
// 레거시 태그 목록(fmDataTag)은 PAT_/OCM_/ETC_ 접두어만 올린다.
|
||||
["퇴원약DT"] = TagPreviewKind.NotATag,
|
||||
["GetOprInfDT"] = TagPreviewKind.NotATag,
|
||||
["GetOprInfDT_OKDMain"] = TagPreviewKind.NotATag,
|
||||
["GetNurseEmrDT"] = TagPreviewKind.NotATag,
|
||||
["GetNurseShtCod"] = TagPreviewKind.NotATag,
|
||||
["GetNurseShtCod_GCRCH"] = TagPreviewKind.NotATag,
|
||||
["GetNurseShtCod_GNBEDRO"] = TagPreviewKind.NotATag,
|
||||
["GetNurseShtCod_SRH"] = TagPreviewKind.NotATag,
|
||||
|
||||
// 이름은 List 로 끝나지만 <b>값 한 줄</b>을 돌려준다(알러지는 CRLF 결합 문자열,
|
||||
// 당뇨식칼로리는 최신 1건) — 아래 "List 끝 = 표" 규칙보다 먼저 잡아야
|
||||
// 환자 해석기가 만든 실값이 "표를 돌려주는 태그" 사유에 가려지지 않는다.
|
||||
["OCM_알러지_List"] = TagPreviewKind.PatientContext,
|
||||
["OCM_당뇨식칼로리_List"] = TagPreviewKind.PatientContext,
|
||||
|
||||
// 반대로 이름이 규칙에 안 걸리는데 <b>DataTable</b> 을 돌려주는 콤보 채움용 4종
|
||||
// (bzDataInterface.vb:16797-16890) — 미리보기 한 줄 값이 아니다.
|
||||
["ETC_전체진료과_한글명"] = TagPreviewKind.Table,
|
||||
["ETC_전체진료과_영문명"] = TagPreviewKind.Table,
|
||||
["ETC_의사List_이름순"] = TagPreviewKind.Table,
|
||||
["ETC_진료과별의사List_이름순"] = TagPreviewKind.Table,
|
||||
// 이름이 "목록"으로 끝나 규칙(List/Datatable 끝)에 안 걸리는 DataTable 반환
|
||||
// (bzDataInterface.vb:15414 — 수술실(OPR) 재직 간호사 이름 표, 콤보 채움용)
|
||||
["ETC_수술간호사목록"] = TagPreviewKind.Table,
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이 태그의 값을 어떻게 얻는가 — 못 얻으면 그 이유</summary>
|
||||
public static TagPreviewKind KindOf(string tag)
|
||||
{
|
||||
if (ClockTags.ContainsKey(tag))
|
||||
{
|
||||
return TagPreviewKind.Clock;
|
||||
}
|
||||
if (UserTags.ContainsKey(tag))
|
||||
{
|
||||
return TagPreviewKind.LoginUser;
|
||||
}
|
||||
if (Exceptions.TryGetValue(tag, out var known))
|
||||
{
|
||||
return known;
|
||||
}
|
||||
// 이름 끝으로 갈리는 두 갈래는 규칙이 확실하다 — 표에 나열하지 않는다.
|
||||
//
|
||||
// <b>Inital 은 오타가 아니라 카탈로그의 실제 표기다.</b> 레거시가 그렇게 적어 두었고
|
||||
// 실제 태그 3종이 <c>_InitalDatatable</c> 이다(LegacyTagCatalog.cs:184, 194, 196).
|
||||
// "InitialDatatable" 만 보면 그 셋이 표가 아니라 환자 문맥으로 분류되어,
|
||||
// 화면이 "환자 정보가 있어야 값이 나온다"는 <b>틀린 사유</b>를 말한다.
|
||||
// 맞춤법을 고치는 것이 아니라 실재하는 표기를 받는 것이다.
|
||||
if (tag.EndsWith("List", StringComparison.Ordinal)
|
||||
|| tag.EndsWith("InitialDatatable", StringComparison.Ordinal)
|
||||
|| tag.EndsWith("InitalDatatable", StringComparison.Ordinal))
|
||||
{
|
||||
return TagPreviewKind.Table;
|
||||
}
|
||||
// 싸인·서명·직인·로고 — 값은 <b>이미지 파일 경로</b>다. 환자 해석기가 경로를 얻어
|
||||
// 이 단말에서 파일을 찾고(레거시 캐시 → 경로 자체), PictureBox 가 그린다.
|
||||
// 파일서버 전송(ActiveX)은 옮기지 않았으므로 캐시가 없는 단말에서는 사유가 된다.
|
||||
// 분류는 PatientContext 다 — Image 로 두면 "글자만 보여 준다"는 <b>틀린 사유</b>가 나간다.
|
||||
if (tag.Contains("싸인", StringComparison.Ordinal)
|
||||
|| tag.Contains("서명", StringComparison.Ordinal)
|
||||
|| tag.Contains("직인", StringComparison.Ordinal)
|
||||
|| tag.Contains("로고", StringComparison.Ordinal))
|
||||
{
|
||||
return TagPreviewKind.PatientContext;
|
||||
}
|
||||
return TagPreviewKind.PatientContext;
|
||||
}
|
||||
|
||||
/// <summary>값을 만들 수 있는 태그인가</summary>
|
||||
public static bool CanResolve(string tag)
|
||||
=> KindOf(tag) is TagPreviewKind.Clock or TagPreviewKind.LoginUser;
|
||||
|
||||
/// <summary>못 만드는 이유를 사람 말로 — 만들 수 있는 태그면 빈 문자열</summary>
|
||||
public static string ReasonText(string tag) => KindOf(tag) switch
|
||||
{
|
||||
TagPreviewKind.PatientContext =>
|
||||
"환자·내원 정보가 있어야 값이 나오는 태그입니다. 서식생성기에는 환자 문맥이 없습니다.",
|
||||
TagPreviewKind.Table =>
|
||||
"값이 아니라 목록(표)을 돌려주는 태그입니다. 콤보를 채우는 용도라 한 줄로 보여 줄 값이 없습니다.",
|
||||
TagPreviewKind.Image =>
|
||||
"파일서버에서 내려받은 이미지 경로를 돌려주는 태그입니다. 여기서는 글자만 보여 줍니다.",
|
||||
TagPreviewKind.Dead =>
|
||||
"레거시에서 항상 빈 값을 돌려주는 태그입니다.",
|
||||
TagPreviewKind.NotATag =>
|
||||
"레거시 내부 함수 이름입니다. 태그로 쓰면 값이 나오지 않습니다.",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>값 옆에 붙일 근거 한 줄</summary>
|
||||
public static string SourceText(string tag) => KindOf(tag) switch
|
||||
{
|
||||
TagPreviewKind.Clock => "서버 시각 기준",
|
||||
TagPreviewKind.LoginUser => "로그인 사용자 기준",
|
||||
_ => string.Empty,
|
||||
};
|
||||
|
||||
/// <summary>서버 시각에서 이 태그의 값을 만든다 — 시각 문자열은 yyyyMMddHHmm(12자리)</summary>
|
||||
public static string FromClock(string tag, string minute12)
|
||||
{
|
||||
if (!ClockTags.TryGetValue(tag, out var format) || minute12.Length < 12)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var date = minute12[..8];
|
||||
return format switch
|
||||
{
|
||||
ClockFormat.Date => $"{date[..4]}-{date[4..6]}-{date[6..8]}",
|
||||
ClockFormat.DateTime => $"{date[..4]}-{date[4..6]}-{date[6..8]} {minute12[8..10]}:{minute12[10..12]}",
|
||||
ClockFormat.DateTimeMask => $"{date[..4]}-{date[4..6]}-{date[6..8]}{minute12[8..10]}:{minute12[10..12]}",
|
||||
// 레거시 Data2Format_ENG 의 MM-DD-YYYY 재배치
|
||||
ClockFormat.DateEnglish => $"{date[4..6]}-{date[6..8]}-{date[..4]}",
|
||||
ClockFormat.Year => date[..4],
|
||||
ClockFormat.YearSuffixed => date[..4] + "년",
|
||||
ClockFormat.Month => date[4..6],
|
||||
ClockFormat.Day => date[6..8],
|
||||
ClockFormat.Hour => minute12[8..10],
|
||||
ClockFormat.Minute => minute12[10..12],
|
||||
ClockFormat.Time => $"{minute12[8..10]}:{minute12[10..12]}",
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>로그인 사용자 정보에서 이 태그의 값을 만든다</summary>
|
||||
public static string FromUser(string tag, string id, string name, string mobile, string office)
|
||||
=> FromUser(tag, id, name, mobile, office, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
/// <summary>9-인자판 — 부서 전화번호가 없던 시절의 호출부를 위해 위임</summary>
|
||||
public static string FromUser(string tag, string id, string name, string mobile, string office,
|
||||
string license, string specialist, string job, string depName)
|
||||
=> FromUser(tag, id, name, mobile, office, license, specialist, job, depName, string.Empty);
|
||||
|
||||
/// <summary>로그인 사용자 정보에서 — 면허·전문의·직종·부서·부서전화까지 받는 본판</summary>
|
||||
public static string FromUser(string tag, string id, string name, string mobile, string office,
|
||||
string license, string specialist, string job, string depName, string depPhone)
|
||||
{
|
||||
if (!UserTags.TryGetValue(tag, out var field))
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
return field switch
|
||||
{
|
||||
UserField.Name => name,
|
||||
UserField.Id => id,
|
||||
// 레거시(bzDataInterface.vb:17930)는 빈 값 검사 없이 <b>항상</b> 결합한다 —
|
||||
// 한쪽이 비면 "id." 나 ".이름" 이 그대로 나간다. 조건을 붙였더니 레거시와 갈렸다.
|
||||
UserField.IdAndName => $"{id}.{name}",
|
||||
UserField.MobilePhone => mobile,
|
||||
UserField.OfficePhone => office,
|
||||
UserField.License => license,
|
||||
UserField.Specialist => specialist,
|
||||
UserField.Job => job,
|
||||
UserField.Department => depName,
|
||||
// 레거시(:17101)는 NVL(DepOfcNam,DepKorNam)||'-'||UidNam — 개원일 미상 결정으로 한글명 고정.
|
||||
// 레거시는 UidNam 으로 걸러 동명이인에서 남의 부서가 나올 수 있는데 여기는 세션 사용자 행이라 결정적이다.
|
||||
UserField.DepartmentAndName => depName.Length > 0 || name.Length > 0 ? $"{depName}-{name}" : string.Empty,
|
||||
UserField.DepPhone => depPhone,
|
||||
_ => string.Empty,
|
||||
};
|
||||
}
|
||||
|
||||
private enum ClockFormat { Date, DateTime, DateTimeMask, DateEnglish, Year, YearSuffixed, Month, Day, Hour, Minute, Time }
|
||||
|
||||
private enum UserField { Name, Id, IdAndName, MobilePhone, OfficePhone, License, Specialist, Job, Department, DepartmentAndName, DepPhone }
|
||||
#endregion
|
||||
}
|
||||
@@ -46,6 +46,28 @@ public static class TagSearch
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 같은 값을 다른 형식으로 내는 태그들 — PAT_주민번호 → _Dash · _Blind.
|
||||
/// 목업의 '관련 필드' 자리를 이것으로 채운다. 없는 개념을 흉내내는 대신
|
||||
/// 지금 데이터로 계산되는 진짜 관계를 쓴다 — 마지막 밑줄 앞까지가 같으면 한 갈래다.
|
||||
/// </summary>
|
||||
public static List<string> VariantsOf(string tag, IEnumerable<string> all)
|
||||
{
|
||||
var cut = tag.LastIndexOf('_');
|
||||
var stem = cut > 0 ? tag[..cut] : tag;
|
||||
// 밑줄이 하나뿐이면 그건 접두어 경계다(PAT_이름) — 줄기가 'PAT' 이 되어 분류 전체가 변형으로 딸려 온다
|
||||
if (stem.Length == 0 || !stem.Contains('_'))
|
||||
{
|
||||
return new List<string>();
|
||||
}
|
||||
return all
|
||||
.Where(t => !string.Equals(t, tag, StringComparison.Ordinal)
|
||||
&& (string.Equals(t, stem, StringComparison.Ordinal)
|
||||
|| t.StartsWith(stem + "_", StringComparison.Ordinal)))
|
||||
.OrderBy(t => t, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 분류 안의 소분류 — 두 번째 토큰. <c>PAT_주민번호_Dash</c> → "주민번호".
|
||||
/// 같은 뿌리에서 갈라진 변형(_Dash, _Blind, _Refer)이 흩어지지 않고 붙어 보이게 한다.
|
||||
@@ -106,10 +128,83 @@ public static class TagSearch
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// 사람이 치는 말을 카탈로그 표기로 옮겨 한 번 더 본다.
|
||||
// 이것 없이는 '환자명'·'날짜'·'사인' 같은 흔한 말이 <b>전부 0건</b>이었다(§Aliases).
|
||||
foreach (var alias in AliasesFor(token))
|
||||
{
|
||||
if (tag.Contains(alias, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return IsChoseongQuery(token)
|
||||
&& ToChoseong(tag).Contains(token, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 사용자가 칠 말 → 카탈로그의 실제 표기.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 검색은 부분 문자열과 초성만 봤고, 카탈로그 표기는 레거시가 정한 것이다.
|
||||
/// 실제로 세어 본 결과 흔한 말이 전부 0건이었다 —
|
||||
/// <c>환자명</c>(표기는 이름) · <c>날짜</c>(일자 21건) · <c>사인</c>(싸인 16건) ·
|
||||
/// <c>주민등록번호</c>(주민번호 5건) · <c>오늘</c>(현재 11건) · <c>도장</c>(직인) ·
|
||||
/// <c>신장</c>(키) · <c>남녀</c>(성별) · <c>성명</c>(이름).
|
||||
/// 서명 태그 16종이 전부 레거시 오표기 <c>싸인</c> 이라, 의료진이 '사인'을 치면
|
||||
/// "결과 없음"만 나오고 회복 경로가 없었다. <b>사용자가 못 찾은 이유는 접두어가 아니라 어휘였다.</b>
|
||||
///
|
||||
/// <b>태그마다 설명을 지어내는 것이 아니다.</b> 여기 있는 것은 <b>입력어 번역표</b>이고
|
||||
/// 오른쪽 값은 전부 카탈로그에 실재하는 표기다. 없는 내용을 만들지 않는다.
|
||||
///
|
||||
/// 한 방향으로만 적어도 양방향으로 듣는다 — '이름'을 쳐도 원래 부분 일치로 걸린다.
|
||||
/// </summary>
|
||||
private static readonly Dictionary<string, string[]> Aliases = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
["환자명"] = new[] { "이름" },
|
||||
["환자이름"] = new[] { "이름" },
|
||||
["성명"] = new[] { "이름" },
|
||||
["이름"] = new[] { "성명" },
|
||||
["주민등록번호"] = new[] { "주민번호" },
|
||||
["주번"] = new[] { "주민번호" },
|
||||
["등록번호"] = new[] { "차트번호" },
|
||||
["차번"] = new[] { "차트번호" },
|
||||
["날짜"] = new[] { "일자" },
|
||||
["날자"] = new[] { "일자" },
|
||||
["오늘"] = new[] { "현재" },
|
||||
["금일"] = new[] { "현재" },
|
||||
["지금"] = new[] { "현재" },
|
||||
["시각"] = new[] { "시간" },
|
||||
["사인"] = new[] { "싸인" },
|
||||
["서명"] = new[] { "싸인" },
|
||||
["싸인"] = new[] { "서명" },
|
||||
["도장"] = new[] { "직인" },
|
||||
["인감"] = new[] { "직인" },
|
||||
["신장"] = new[] { "키" },
|
||||
["남녀"] = new[] { "성별" },
|
||||
["성"] = new[] { "성별" },
|
||||
["전화"] = new[] { "연락처", "전화" },
|
||||
["폰"] = new[] { "연락처", "전화" },
|
||||
["핸드폰"] = new[] { "휴대", "연락처" },
|
||||
["휴대폰"] = new[] { "휴대", "연락처" },
|
||||
["번호"] = new[] { "차트번호", "주민번호" },
|
||||
["병원"] = new[] { "요양기관", "병원" },
|
||||
["의사"] = new[] { "의사", "담당" },
|
||||
["과"] = new[] { "진료과" },
|
||||
["주소"] = new[] { "주소", "도로명" },
|
||||
["나이"] = new[] { "나이", "연령" },
|
||||
["연령"] = new[] { "나이" },
|
||||
["생일"] = new[] { "생년월일" },
|
||||
};
|
||||
|
||||
private static string[] AliasesFor(string token)
|
||||
=> Aliases.TryGetValue(token, out var found) ? found : Array.Empty<string>();
|
||||
|
||||
/// <summary>이 말이 카탈로그 표기와 다른가 — 화면이 "'사인'은 '싸인'으로 찾습니다"를 알릴 때 쓴다</summary>
|
||||
public static string? AliasHintFor(string query)
|
||||
{
|
||||
var token = query.Trim();
|
||||
return Aliases.TryGetValue(token, out var found) && found.Length > 0 ? found[0] : null;
|
||||
}
|
||||
|
||||
/// <summary>공백으로 나눈 여러 토큰을 전부 만족해야 통과(AND)</summary>
|
||||
public static bool MatchesAll(string tag, IReadOnlyList<string> tokens)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
namespace SheetMe.Core.Catalog;
|
||||
|
||||
/// <summary>내원 구간에서 어느 끝을 기준으로 삼을지 — 레거시 EN_FirstorLast</summary>
|
||||
public enum VisitEdge
|
||||
{
|
||||
/// <summary>마지막 시점(기본) — 퇴원·자격 종료 쪽</summary>
|
||||
Last,
|
||||
|
||||
/// <summary>첫 시점 — 접수·자격 시작 쪽</summary>
|
||||
First,
|
||||
}
|
||||
|
||||
/// <summary>적용일시 판정에 필요한 내원 정보 — 전부 YYYYMMDDHHMM / YYYYMMDD 문자열이다</summary>
|
||||
/// <param name="Inpatient">입원인가(ComPatTyp = 'I')</param>
|
||||
/// <param name="AcceptedAt">ComAcpDtm — 접수일시(12자리)</param>
|
||||
/// <param name="LeftAt">ComLevDtm — 퇴원일시(12자리). 재원 중이면 비거나 미래값</param>
|
||||
/// <param name="CoverageFrom">CoiStrDte — 자격 시작일(8자리)</param>
|
||||
/// <param name="CoverageTo">CoiEndDte — 자격 종료일(8자리). 재원 중이면 29991231</param>
|
||||
public readonly record struct VisitWindow(
|
||||
bool Inpatient, string AcceptedAt, string LeftAt, string CoverageFrom, string CoverageTo);
|
||||
|
||||
/// <summary>
|
||||
/// 내원의 <b>적용일시</b>(AdpDtm)를 정한다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> +
|
||||
/// <c>dtCommonLib.GetComAdpDtm</c> 의 판정을 옮긴 것.
|
||||
///
|
||||
/// <b>왜 이것이 중요한가.</b> 환자 문맥 5행이 전부 이 시각을 키로 읽힌다 —
|
||||
/// 진료과·담당의(<c>P_CodInf</c>), 보험 자격(<c>P_CoiInf</c>), 병동·병실(<c>P_CowInf</c>) 모두
|
||||
/// <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다. 시각이 한 칸 어긋나면 조인이 0행이 되고
|
||||
/// <b>태그가 전부 빈 값</b>이 된다. 값이 틀리는 것이 아니라 통째로 안 나온다.
|
||||
///
|
||||
/// <b>순수 함수로 두는 이유.</b> 갈래가 여섯이고(외래/입원×First/Last, 재원 중, 호출자 지정)
|
||||
/// 각각 경계 조건이 다르다. 저장소 안에 묻어 두면 실DB 없이는 한 갈래도 확인할 수 없다.
|
||||
///
|
||||
/// 시각 비교는 <b>문자열 사전순</b>으로 한다 — 레거시가 그렇게 하고, 고정폭 YYYYMMDDHHMM 은
|
||||
/// 사전순이 곧 시간순이다. DateTime 으로 바꾸면 잘못된 값(빈 문자열·공백)에서 예외가 난다.
|
||||
/// </summary>
|
||||
public static class VisitMoment
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>자격 종료일이 이 값이면 아직 진행 중이다(레거시 관행)</summary>
|
||||
private const string OpenEnded = "29991231";
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 호출자가 시각을 주지 않았을 때 — 내원 자체에서 정한다
|
||||
/// (<c>dtCommonLib.GetComAdpDtm</c>, :3289-3346).
|
||||
/// </summary>
|
||||
/// <param name="serverNow">DB 서버 시각 12자리. 단말 시계를 쓰지 않는다</param>
|
||||
public static string Decide(VisitWindow visit, VisitEdge edge, string serverNow)
|
||||
{
|
||||
// 외래는 접수일시가 곧 적용일시다 — 자격 구간을 보지 않는다(:3344)
|
||||
if (!visit.Inpatient)
|
||||
{
|
||||
return Clean(visit.AcceptedAt);
|
||||
}
|
||||
if (edge == VisitEdge.First)
|
||||
{
|
||||
// 자격 시작과 접수 중 <b>늦은</b> 쪽 — 자격이 접수 전에 시작했어도 내원 밖은 의미가 없다(:3334-3338)
|
||||
return Later(Day(visit.CoverageFrom, "0000"), Clean(visit.AcceptedAt));
|
||||
}
|
||||
if (Clean(visit.CoverageTo) == OpenEnded)
|
||||
{
|
||||
// 재원 중 — 지금이 기준이다. 단 입원 전환이 미래로 잡혀 있으면 접수 시각을 쓴다(:3310-3316)
|
||||
var now = Clean(serverNow);
|
||||
return Compare(now, Clean(visit.AcceptedAt)) <= 0 ? Clean(visit.AcceptedAt) : now;
|
||||
}
|
||||
// 자격 종료와 퇴원 중 <b>이른</b> 쪽 — 둘 중 먼저 끝난 것이 실제 끝이다(:3320-3328)
|
||||
return Earlier(Day(visit.CoverageTo, "2359"), Clean(visit.LeftAt));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 호출자가 시각을 준 경우 — 내원 구간 안으로 <b>가둔다</b>
|
||||
/// (<c>bzPatientInfo.vb:441-511</c>).
|
||||
///
|
||||
/// 가두지 않으면 내원 밖 시각으로 자격·병실을 조회해 0행이 나온다.
|
||||
/// 해석할 수 없는 값이면 빈 문자열을 돌려준다 — 호출부가 <see cref="Decide"/> 로 넘어가야 한다.
|
||||
/// </summary>
|
||||
public static string Clamp(string requested, VisitWindow visit, VisitEdge edge)
|
||||
{
|
||||
var value = Clean(requested);
|
||||
var accepted = Clean(visit.AcceptedAt);
|
||||
var left = Clean(visit.LeftAt);
|
||||
|
||||
// 8자리(일자) — 그 날의 어느 끝을 쓸지 edge 가 정하고, 내원 경계가 더 안쪽이면 경계를 쓴다
|
||||
if (value.Length == 8)
|
||||
{
|
||||
if (edge == VisitEdge.Last)
|
||||
{
|
||||
var candidate = value + "2359";
|
||||
return left.Length >= 12 && Compare(left, candidate) <= 0 ? left : candidate;
|
||||
}
|
||||
var first = value + "0000";
|
||||
return accepted.Length >= 12 && Compare(accepted, first) >= 0 ? accepted : first;
|
||||
}
|
||||
|
||||
// 12자리 이상(일시) — 접수 전이면 접수로, 퇴원 후면 퇴원으로 당긴다
|
||||
if (value.Length >= 12)
|
||||
{
|
||||
var moment = value[..12];
|
||||
if (accepted.Length >= 12 && Compare(moment, accepted) < 0)
|
||||
{
|
||||
return accepted;
|
||||
}
|
||||
if (left.Length >= 12 && Compare(moment, left) > 0)
|
||||
{
|
||||
return left;
|
||||
}
|
||||
return moment;
|
||||
}
|
||||
|
||||
// 해석할 수 없다 — 레거시도 빈 문자열로 둔다(:505, :511)
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 호출자 지정과 자동 판정을 한 번에 — 화면에서 쓰는 진입점.
|
||||
/// 지정값이 해석되지 않으면 자동 판정으로 떨어진다.
|
||||
/// </summary>
|
||||
public static string Resolve(string? requested, VisitWindow visit, VisitEdge edge, string serverNow)
|
||||
{
|
||||
var clamped = Clamp(requested ?? string.Empty, visit, edge);
|
||||
return clamped.Length > 0 ? clamped : Decide(visit, edge, serverNow);
|
||||
}
|
||||
|
||||
/// <summary>8자리 일자에 시각을 붙인다 — 일자가 없으면 빈 문자열</summary>
|
||||
private static string Day(string date, string time)
|
||||
{
|
||||
var value = Clean(date);
|
||||
return value.Length >= 8 ? value[..8] + time : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>둘 중 늦은 쪽. 한쪽이 비면 다른 쪽</summary>
|
||||
private static string Later(string a, string b)
|
||||
=> a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) >= 0 ? a : b;
|
||||
|
||||
/// <summary>둘 중 이른 쪽. 한쪽이 비면 다른 쪽</summary>
|
||||
private static string Earlier(string a, string b)
|
||||
=> a.Length == 0 ? b : b.Length == 0 ? a : Compare(a, b) <= 0 ? a : b;
|
||||
|
||||
private static int Compare(string a, string b) => string.CompareOrdinal(a, b);
|
||||
|
||||
/// <summary>CHAR 고정폭 잔여 공백을 흡수한다 — 이 컬럼들은 전부 고정폭이다</summary>
|
||||
private static string Clean(string? value) => (value ?? string.Empty).Trim();
|
||||
#endregion
|
||||
}
|
||||
@@ -14,6 +14,17 @@ public static class LegacyTypeCatalog
|
||||
/// <summary>페이지 루트 클래스 단축명</summary>
|
||||
public const string HostClassName = "MDesignerHost";
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스의 <b>중립</b> 타입명 — 레거시 클래스명은 <c>MDataTable</c> 이고 여기서 이 이름으로 바뀐다.
|
||||
///
|
||||
/// <b>상수로 두는 이유.</b> 두 이름이 공존해서 실제로 사고가 났다.
|
||||
/// 배선 실행기가 레거시 이름(<c>MDataTable</c>)으로 컨트롤을 찾고 있었는데,
|
||||
/// 서식을 XML 에서 읽으면 그 시점에 이미 중립 이름으로 바뀌어 있어 <b>한 개도 못 찾았다</b> —
|
||||
/// 실사용 서식의 데이터소스 배선이 전부 "이 서식에 없습니다"로 죽었다.
|
||||
/// 문자열을 각자 적는 한 같은 일이 또 난다.
|
||||
/// </summary>
|
||||
public const string DataTableType = "DataTable";
|
||||
|
||||
/// <summary>페이지 루트 AQN (실샘플 sheetdesign.xml 원문 확인)</summary>
|
||||
public const string HostAqn =
|
||||
"M.EMR.UserControl.MDesignerHost, M.EMR.UserControl, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null";
|
||||
@@ -42,7 +53,7 @@ public static class LegacyTypeCatalog
|
||||
["MPictureBox"] = "PictureBox",
|
||||
["MCalcBox"] = "CalcBox",
|
||||
["MButton"] = "Button",
|
||||
["MDataTable"] = "DataTable",
|
||||
["MDataTable"] = DataTableType,
|
||||
// Spread 는 읽기 매핑만(렌더) — E_SpdMst 디자인 없이는 신규 생성 불가하므로 쓰기 매핑/팔레트 미등록
|
||||
["Spread"] = "Spread",
|
||||
};
|
||||
|
||||
@@ -43,6 +43,20 @@ public sealed class DataConfig
|
||||
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
|
||||
/// </summary>
|
||||
public string DevUidCod { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 싸인·직인 이미지의 <b>서버 경로 → UNC 공유</b> 대응 — <c>"서버접두=UNC접두"</c> 목록.
|
||||
/// 예: <c>D:\MsystechHIS=\\192.168.100.222\MsystechHIS</c>
|
||||
///
|
||||
/// <b>왜 설정인가.</b> DB 는 이미지 위치를 파일서버의 <b>로컬 절대경로</b>로 들고 있고
|
||||
/// (실측: <c>D:\MsystechHIS\{사이트}\…</c>), 레거시는 그 경로를 ActiveX OCX 데몬
|
||||
/// (기본 포트 2002)에 넘겨 받아 온다. 그 프로토콜은 재현할 수 없으므로 우리는 같은 파일을
|
||||
/// <b>공유 폴더로 직접</b> 읽는다 — 접두 대응은 병원마다 다르니 코드가 아니라 설정이다.
|
||||
///
|
||||
/// 비어 있으면(기본) 매핑을 시도하지 않는다 — 지금까지와 같이 로컬 캐시만 본다.
|
||||
/// <b>자격증명은 담지 않는다</b>: 공유 접근 권한은 단말 로그인 계정으로 해결한다.
|
||||
/// </summary>
|
||||
public string[] SignatureShareMap { get; set; } = Array.Empty<string>();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
|
||||
@@ -103,6 +103,31 @@ public static class ServerInfoReader
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// HIS 설치 경로 — ini 의 <c>[MSYSTECHHIS\Install] Path</c>(레거시
|
||||
/// <c>GetInstallPathFromServerInfoFile</c>, clsCommonLib.vb:271-287 과 같은 값).
|
||||
/// 서명 이미지 캐시 폴더의 뿌리다. 이 값은 암호화되어 있지 않다(경로일 뿐).
|
||||
/// ini 가 없거나 값이 비면 null — 레거시는 여기서 오류창을 띄우고 빈 문자열을 돌려줘
|
||||
/// 폴더가 루트 상대경로("\Tmp\...")가 되어 버린다. 그 결함은 옮기지 않는다.
|
||||
/// </summary>
|
||||
public static string? InstallPath(string? iniPath = null)
|
||||
{
|
||||
var path = ResolveIniPath(iniPath);
|
||||
if (path is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
var value = ReadValue(File.ReadAllLines(path), @"MSYSTECHHIS\Install", "Path");
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin)</summary>
|
||||
private static string? ResolveIniPath(string? iniPath)
|
||||
{
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 상병(진단) 조회 — 레거시 <c>Okd_상병쿼리</c>(bzDataInterface.vb:14160-14623)의 이식.
|
||||
///
|
||||
/// <b>이 DB(SRCH)가 타는 Case Else 갈래만 옮겼다.</b> 원문은 병원 코드로 여섯 갈래
|
||||
/// (KIMEYE·NYJB·SYBS·HIMCHAN_CW·SPHH·Else)로 갈리는데, 그 다섯은 SELECT 목록·이름 결합이
|
||||
/// 제각각이라 각각 별도 이식이다. 그 병원에서는 태그가 <b>미이식이라고 말한다</b> —
|
||||
/// Else 갈래로 얼버무리면 그 병원 서식에 다른 모양의 상병명이 조용히 찍힌다.
|
||||
///
|
||||
/// <b>SQL 은 원문에서 기계 추출했다</b>(Select Case 를 Case Else 경로로 시뮬레이션) —
|
||||
/// 손으로 다시 치면 공백·결합 규칙이 달라진다. 구조는:
|
||||
/// 블록1(M_KcdMst 와 맞는 상병) UNION 블록2(마스터에 없는 코드 — LEFT JOIN 후 KcdCod IS NULL)
|
||||
/// 각 블록에 주/부 필터가 붙고 블록마다 ROW_NUMBER=1 로 접은 뒤 OkdDspSeq 순으로 정렬한다.
|
||||
///
|
||||
/// 값은 이 저장소가 만들지 않는다 — 행 목록만 돌려주고 결합(가로/세로·확진/의증 접미)은
|
||||
/// 해석기가 한다. 태그 8종이 같은 결과셋을 다르게 자를 뿐이기 때문이다.
|
||||
/// </summary>
|
||||
public sealed class DiagnosisStore
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>주/부 구분 — 레거시 psOkdTyp(T=전체, M=주상병, S=부상병)</summary>
|
||||
public enum Scope { All, Main, Sub }
|
||||
|
||||
private readonly string connectionString;
|
||||
|
||||
/// <summary>원문 그대로의 쿼리 틀 — {TYPE} 자리에 주/부 필터가 들어간다</summary>
|
||||
private const string Template = @"
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
|
||||
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdElcCod || '-' || TRIM(KcdSeq) AS KcdSeqCod, OkdPoaTyp
|
||||
, OkdDtrNam
|
||||
, ROW_NUMBER() OVER(PARTITION BY KcdCod ORDER BY KcdStrDte DESC) AS OKD_RN
|
||||
FROM O_OkdInf
|
||||
INNER JOIN P_ComInf
|
||||
ON OkdComNum = ComNum
|
||||
INNER JOIN M_KcdMst
|
||||
ON KcdCod = TRIM(OkdKcdCod)
|
||||
AND TRIM(OkdKcdSeq) = KcdSeq
|
||||
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
|
||||
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
|
||||
WHERE OkdComNum = :piComNum
|
||||
AND OkdStt = 'E'
|
||||
{TYPE}
|
||||
) OKD
|
||||
WHERE OKD_RN = 1
|
||||
|
||||
UNION
|
||||
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
|
||||
, KCD.KcdCod, KCD.KcdElcCod, KCD.KcdKorNam, KCD.KcdEngNam, KCD.KcdElcCod || '-' || TRIM(KCD.KcdSeq) AS KcdSeqCod, OkdPoaTyp
|
||||
, OkdDtrNam
|
||||
, ROW_NUMBER() OVER(PARTITION BY KCD.KcdCod ORDER BY KCD.KcdStrDte DESC) AS OKD_RN
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf, OkdVeeCod
|
||||
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam, KcdSeq, OkdPoaTyp
|
||||
, OkdDtrNam
|
||||
FROM O_OkdInf
|
||||
INNER JOIN P_ComInf
|
||||
ON OkdComNum = ComNum
|
||||
LEFT JOIN M_KcdMst
|
||||
ON KcdCod = TRIM(OkdKcdCod)
|
||||
AND TRIM(OkdKcdSeq) = KcdSeq
|
||||
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
|
||||
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
|
||||
WHERE OkdComNum = :piComNum
|
||||
AND OkdStt = 'E'
|
||||
) OKD
|
||||
INNER JOIN M_KcdMst KCD
|
||||
ON KCD.KcdCod = TRIM(OkdKcdCod)
|
||||
AND OkdKcdSeq = KCD.KcdSeq
|
||||
WHERE 1=1
|
||||
AND (OKD.KcdCod IS NULL OR OKD.KcdCod = ' ')
|
||||
{TYPE}
|
||||
) OKD
|
||||
WHERE OKD_RN = 1
|
||||
)
|
||||
ORDER BY OkdDspSeq
|
||||
";
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public DiagnosisStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이 내원의 상병 행들 — 표시 순서(OkdDspSeq)대로. 없으면 빈 목록</summary>
|
||||
public List<Dictionary<string, string>> Diagnoses(decimal comNum, Scope scope = Scope.All)
|
||||
{
|
||||
var filter = scope switch
|
||||
{
|
||||
Scope.Main => " AND OkdDspSeq = '1' ",
|
||||
Scope.Sub => " AND OkdDspSeq <> '1' ",
|
||||
_ => string.Empty,
|
||||
};
|
||||
var sql = Template.Replace("{TYPE}", filter);
|
||||
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.Add(new OracleParameter("piComNum", comNum));
|
||||
|
||||
var rows = new List<Dictionary<string, string>>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row[reader.GetName(i)] = reader.IsDBNull(i)
|
||||
? string.Empty
|
||||
: reader.GetValue(i).ToString()!.Trim();
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -214,6 +214,17 @@ public sealed class OracleLegacyFormStore
|
||||
}
|
||||
|
||||
/// <summary>특정 버전(SdgKey) 디자인 로드 — 이력 열람/복원용. 없으면 null</summary>
|
||||
/// <summary>특정 버전의 원문 XML — 이력 행이 옛 내용을 담았는지 확인하는 데 쓴다</summary>
|
||||
public string? LoadDesignRawByKey(string shtCod, decimal sdgKey)
|
||||
{
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = "SELECT SdgDesign FROM E_SdgMst WHERE SdgKey = :k";
|
||||
command.Parameters.Add(new OracleParameter("k", sdgKey));
|
||||
return command.ExecuteScalar() as string;
|
||||
}
|
||||
|
||||
public FormDocument? LoadDesignByKey(string shtCod, decimal sdgKey)
|
||||
{
|
||||
using var connection = Open();
|
||||
@@ -286,6 +297,134 @@ public sealed class OracleLegacyFormStore
|
||||
/// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션.
|
||||
/// 반환: 저장된 SdgKey.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 지금 쓰이는 버전을 <b>제자리에서</b> 정정한다 — 레거시 <c>TK_MODIFY</c>(3-인자 오버로드).
|
||||
///
|
||||
/// <b>저장과 이력 방향이 반대다.</b> 저장은 옛 행을 <c>SdgDelYon='Y'</c> 로 내리고 새 행이 활성이 된다.
|
||||
/// 수정은 <b>활성 행이 제자리에서 갱신되고, 옛 XML 을 담은 새 행이 이력</b>으로 들어간다.
|
||||
/// 그래서 <c>SdgKey</c> 가 바뀌지 않는다 — EMR 이 참조하던 키가 그대로 유지된다.
|
||||
///
|
||||
/// <b><c>E_SctMst</c> 를 건드리지 않는다.</b> 레거시가 그 부분을 주석 처리해 두었고
|
||||
/// (<c>bzSaveSheetDesignNControlInfo.vb:170-174</c>), 그것이 "글자만 수정 가능"이라는 경고문의
|
||||
/// 기술적 근거다. SdgKey 가 그대로라 기존 매핑 행은 유효하지만 <b>새 컨트롤은 매핑 행이 안 생긴다</b>.
|
||||
/// 호출부가 <see cref="NewControlNames"/> 로 먼저 막는다.
|
||||
///
|
||||
/// <b>레거시 버그 둘은 복제하지 않는다.</b>
|
||||
/// ① 감사 컬럼이 뒤집혀 있었다 — 활성 행에는 아무것도 안 쓰고
|
||||
/// <b>옛 디자인을 담은 이력 행</b>이 현재 사용자·시각을 받았다(<c>:162-163</c>).
|
||||
/// 그러면 이력 패널이 "누가 언제 이 버전을 만들었는가"를 거꾸로 보여 준다.
|
||||
/// 여기서는 활성 행에 기록하고, 이력 행은 원래 값을 지킨다.
|
||||
/// ② <c>Rows(0)</c> 을 개수 검사보다 먼저 읽어 활성 행이 없으면 예외였다(<c>:150</c>).
|
||||
/// 여기서는 먼저 확인하고 뜻이 통하는 오류를 낸다.
|
||||
/// </summary>
|
||||
public decimal ModifyDesign(FormDocument document, string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.FormId))
|
||||
{
|
||||
throw new InvalidOperationException("서식 코드(FormId)가 없습니다.");
|
||||
}
|
||||
var mergedXml = serializer.Write(document);
|
||||
|
||||
using var connection = Open();
|
||||
using var transaction = connection.BeginTransaction();
|
||||
try
|
||||
{
|
||||
var now = ServerClock.Read(connection, transaction).Minute12;
|
||||
var activeSdgKey = ResolveActiveSdgKey(connection, transaction, document.FormId);
|
||||
if (activeSdgKey <= 0)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"서식 [{document.FormId}] 에 지금 쓰이는 디자인이 없습니다. 수정 대신 저장을 쓰세요.");
|
||||
}
|
||||
|
||||
// 옛 디자인과 그 감사 값을 먼저 떠 둔다 — 이력 행이 그 값을 그대로 가져가야
|
||||
// "그 버전을 누가 언제 만들었는지"가 보존된다.
|
||||
string oldXml = string.Empty, oldUid = uid, oldStamp = now;
|
||||
using (var read = connection.CreateCommand())
|
||||
{
|
||||
read.Transaction = transaction;
|
||||
read.BindByName = true;
|
||||
read.CommandText =
|
||||
"SELECT SdgDesign, NVL(SdgUidCod,' '), NVL(SdgUpdDtm,' ') FROM E_SdgMst WHERE SdgKey = :k";
|
||||
read.Parameters.Add(new OracleParameter("k", activeSdgKey));
|
||||
using var reader = read.ExecuteReader();
|
||||
if (reader.Read())
|
||||
{
|
||||
oldXml = reader.IsDBNull(0) ? string.Empty : reader.GetString(0);
|
||||
oldUid = reader.GetString(1).Trim();
|
||||
oldStamp = reader.GetString(2).Trim();
|
||||
}
|
||||
}
|
||||
|
||||
NonQuery(connection, transaction,
|
||||
"UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k",
|
||||
new OracleParameter("d", OracleDbType.Clob) { Value = mergedXml },
|
||||
new OracleParameter("u", uid),
|
||||
new OracleParameter("t", now),
|
||||
new OracleParameter("k", activeSdgKey));
|
||||
|
||||
var historyKey = Convert.ToDecimal(Scalar(connection, transaction,
|
||||
"SELECT NVL(MAX(SdgKey),0)+1 FROM E_SdgMst"));
|
||||
NonQuery(connection, transaction,
|
||||
"INSERT INTO E_SdgMst (SdgKey, SdgShtCod, SdgDesign, SdgUidCod, SdgUpdDtm,"
|
||||
+ " SdgDelYon, SdgDelUid, SdgDelDtm)"
|
||||
+ " VALUES (:k, :s, :d, :u, :t, 'Y', '', '')",
|
||||
new OracleParameter("k", historyKey),
|
||||
new OracleParameter("s", document.FormId),
|
||||
new OracleParameter("d", OracleDbType.Clob) { Value = oldXml },
|
||||
new OracleParameter("u", oldUid.Length > 0 ? oldUid : uid),
|
||||
new OracleParameter("t", oldStamp.Length > 0 ? oldStamp : now));
|
||||
|
||||
transaction.Commit();
|
||||
return activeSdgKey;
|
||||
}
|
||||
catch
|
||||
{
|
||||
transaction.Rollback();
|
||||
throw;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 문서에 있는데 지금 활성 버전의 <c>E_SctMst</c> 에는 없는 컨트롤 이름들.
|
||||
///
|
||||
/// 수정은 <c>E_SctMst</c> 를 안 쓰므로 이 이름들은 <b>매핑 행 없이</b> 저장되고
|
||||
/// EMR 에서 조용히 값이 안 채워진다. 그래서 호출부가 수정을 막는 근거로 쓴다.
|
||||
/// 판정 근거를 <see cref="SctMstXmlWalker"/> 로 통일한다 — 저장이 행을 만드는 규칙과 같은 것이라
|
||||
/// "행이 원래 안 생기는 종류"를 새 컨트롤로 오인하지 않는다.
|
||||
/// </summary>
|
||||
public List<string> NewControlNames(FormDocument document)
|
||||
{
|
||||
var names = new List<string>();
|
||||
using var connection = Open();
|
||||
var activeSdgKey = ResolveActiveSdgKey(connection, null, document.FormId);
|
||||
if (activeSdgKey <= 0)
|
||||
{
|
||||
return names;
|
||||
}
|
||||
var existing = new HashSet<string>(StringComparer.Ordinal);
|
||||
using (var query = connection.CreateCommand())
|
||||
{
|
||||
query.BindByName = true;
|
||||
query.CommandText = "SELECT SctObjNam FROM E_SctMst WHERE SctSdgKey = :k";
|
||||
query.Parameters.Add(new OracleParameter("k", activeSdgKey));
|
||||
using var reader = query.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
existing.Add(reader.GetString(0));
|
||||
}
|
||||
}
|
||||
foreach (var row in SctMstXmlWalker.Walk(serializer.Write(document)))
|
||||
{
|
||||
if (!string.Equals(row.ObjType, SpreadItemType, StringComparison.OrdinalIgnoreCase)
|
||||
&& !existing.Contains(row.ObjName) && !names.Contains(row.ObjName))
|
||||
{
|
||||
names.Add(row.ObjName);
|
||||
}
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
public decimal SaveDesign(FormDocument document, string uid)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(document.FormId))
|
||||
|
||||
@@ -0,0 +1,423 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 한 내원의 환자 문맥 — 레거시 bzPatientInfo 의 5 DataRow 에 대응한다.
|
||||
/// 각 행은 컬럼명 → 값 사전이다. 태그가 <c><<Patient.PatInfDR.item("PatNam")>></c> 처럼
|
||||
/// 컬럼명으로 지목하므로(LegacyQueryVariableCatalog.cs:130-135) 이 모양이 그대로 맞는다.
|
||||
/// </summary>
|
||||
public sealed record PatientContext(
|
||||
decimal ComNum,
|
||||
string AdpDtm,
|
||||
IReadOnlyDictionary<string, string> PatInf,
|
||||
IReadOnlyDictionary<string, string> ComInf,
|
||||
IReadOnlyDictionary<string, string> CodInf,
|
||||
IReadOnlyDictionary<string, string> CoiInf,
|
||||
IReadOnlyDictionary<string, string> CowInf)
|
||||
{
|
||||
/// <summary>행 하나에서 컬럼 값 — 없으면 빈 문자열</summary>
|
||||
public static string Value(IReadOnlyDictionary<string, string> row, string column)
|
||||
=> row.TryGetValue(column, out var value) ? value : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 내원번호 하나로 환자 문맥을 읽는다 — 레거시 <c>bzPatientInfo.SetPatientInfo</c> 대응.
|
||||
///
|
||||
/// <b>다섯 행이 전부 적용일시를 키로 쓴다.</b> 진료과·담당의(P_CodInf), 보험 자격(P_CoiInf),
|
||||
/// 병동·병실(P_CowInf) 은 <c>:AdpDtm BETWEEN StrDtm AND EndDtm</c> 이다 —
|
||||
/// 시각이 어긋나면 조인이 0행이 되고 태그가 통째로 빈다.
|
||||
/// 그래서 시각 판정은 <see cref="Core.Catalog.VisitMoment"/> 가 따로 맡고(테스트 9건) 여기서는 그것을 받아 쓴다.
|
||||
///
|
||||
/// <b>레거시와 다른 점</b>: 레거시는 5행을 프로퍼티 접근 시점에 하나씩 지연 로딩한다
|
||||
/// (bzPatientInfo.vb:764-980, 각각 별도 왕복). 여기서는 <b>한 번에 다섯 번</b> 읽고 들고 있는다 —
|
||||
/// 태그마다 다시 읽으면 태그 40개짜리 서식에 왕복 40회가 된다.
|
||||
///
|
||||
/// 전부 바인드 변수다. 레거시 dtHISOperatingInfo 도 이 다섯 개만은 바인드를 쓴다.
|
||||
/// </summary>
|
||||
public sealed class PatientContextStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PatientContextStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이 내원의 문맥 — 내원 자체가 없으면 null</summary>
|
||||
public PatientContext? Load(decimal comNum, Core.Catalog.VisitEdge edge = Core.Catalog.VisitEdge.Last)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var comInf = Row(connection, "SELECT * FROM P_ComInf WHERE ComNum = :k", ("k", comNum));
|
||||
if (comInf.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var window = new Core.Catalog.VisitWindow(
|
||||
PatientContext.Value(comInf, "COMPATTYP") == "I",
|
||||
PatientContext.Value(comInf, "COMACPDTM"),
|
||||
PatientContext.Value(comInf, "COMLEVDTM"),
|
||||
string.Empty, string.Empty);
|
||||
|
||||
// 자격 구간은 P_CoiInf 가 갖고 있다 — 시각을 정하려면 먼저 읽어야 하고,
|
||||
// 그 읽기 자체는 시각을 쓰지 않는다(MtiSeq 0 의 최신 행).
|
||||
var coiSeed = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
|
||||
+ " ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1", ("k", comNum));
|
||||
window = window with
|
||||
{
|
||||
CoverageFrom = PatientContext.Value(coiSeed, "COISTRDTE"),
|
||||
CoverageTo = PatientContext.Value(coiSeed, "COIENDDTE"),
|
||||
};
|
||||
|
||||
var adpDtm = Core.Catalog.VisitMoment.Decide(window, edge, ServerClock.Read(connection, null).Minute12);
|
||||
var chtNum = PatientContext.Value(comInf, "COMCHTNUM");
|
||||
|
||||
var patInf = Row(connection, "SELECT * FROM P_PatInf WHERE TRIM(PatChtNum) = :c", ("c", chtNum.Trim()));
|
||||
var codInf = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
|
||||
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
|
||||
("k", comNum), ("t", adpDtm));
|
||||
var coiInf = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM P_CoiInf WHERE CoiComNum = :k AND CoiMtiSeq = 0"
|
||||
+ " AND :d BETWEEN CoiStrDte AND CoiEndDte ORDER BY CoiStrDte DESC) WHERE ROWNUM = 1",
|
||||
("k", comNum), ("d", Day(adpDtm)));
|
||||
var cowInf = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM P_CowInf WHERE CowComNum = :k"
|
||||
+ " AND :t BETWEEN CowStrDtm AND CowEndDtm ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
|
||||
("k", comNum), ("t", adpDtm));
|
||||
|
||||
return new PatientContext(comNum, adpDtm, patInf, comInf, codInf, coiInf, cowInf);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 시점의 진료과 코드 — 레거시 <c>GetCodInfDT(comNum, moment, 1)</c> 의 첫 행 대응.
|
||||
/// 입원과(접수 시점)·퇴원과(퇴원/현재 시점) 태그가 문맥의 적용일시와 <b>다른 시점</b>을
|
||||
/// 물을 때 쓴다. 원문 GetCodInfDT 는 ORDER BY 가 없어 임의 행이었다 — 문맥 로드와
|
||||
/// 같은 규칙(시작일시 최신 1행)으로 고정한다. moment 가 비면 서버 현재시각이다.
|
||||
/// </summary>
|
||||
public string DepartmentCodeAt(decimal comNum, string? moment)
|
||||
=> PatientContext.Value(CodInfAt(comNum, moment), "CodDepCod");
|
||||
|
||||
/// <summary>특정 시점의 진료 행(진료과 코드 + 담당의 코드) — <see cref="DepartmentCodeAt"/> 의 원형</summary>
|
||||
public IReadOnlyDictionary<string, string> CodInfAt(decimal comNum, string? moment)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
var at = (moment ?? string.Empty).Trim();
|
||||
return Row(connection,
|
||||
"SELECT CodDepCod, CodDtrCod FROM (SELECT CodDepCod, CodDtrCod FROM P_CodInf"
|
||||
+ " WHERE CodComNum = :k AND CodMtiSeq = 0"
|
||||
+ (at.Length >= 12
|
||||
? " AND :t BETWEEN CodStrDtm AND CodEndDtm"
|
||||
: " AND TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI') BETWEEN CodStrDtm AND CodEndDtm")
|
||||
+ " ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
|
||||
at.Length >= 12
|
||||
? new (string, object)[] { ("k", comNum), ("t", at[..12]) }
|
||||
: new (string, object)[] { ("k", comNum) });
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 특정 일자에 유효한 사용자 이름(M_UidMst.UidNam) — 입원의사·퇴원의사가 쓴다
|
||||
/// (bzDataInterface.vb:6520-6527). 유효기간이 겹치는 이중 행은 정상 데이터가 아니지만
|
||||
/// 원문의 Rows(0) 대신 한 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string UserNameAt(string uidCod, string day)
|
||||
{
|
||||
if (uidCod.Trim().Length == 0 || day.Trim().Length < 8)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
var row = Row(connection,
|
||||
"SELECT UidNam FROM (SELECT UidNam FROM M_UidMst WHERE UidCod = :u"
|
||||
+ " AND :d BETWEEN UidStrDte AND UidEndDte ORDER BY UidStrDte DESC) WHERE ROWNUM = 1",
|
||||
("u", uidCod.Trim()), ("d", day.Trim()[..8]));
|
||||
return PatientContext.Value(row, "UidNam").Trim();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 협진 목록(bzDataInterface.vb:6360-6494) — 회신 완료(CstSttFlg='G')된 협진의
|
||||
/// 회신 과(+의사). 원문 그대로: DISTINCT + 시작일 순 + 앞에서 과는 2건, 과·의사는 3건.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> Consults(decimal comNum, bool withDoctor)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = withDoctor
|
||||
? "SELECT * FROM (SELECT DISTINCT UidCod, UidNam, DepCod, DepKorNam, CstStrDte FROM O_CstInf"
|
||||
+ " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
|
||||
+ " INNER JOIN M_UidMst ON CstRplDtr = UidCod AND CstStrDte BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 3"
|
||||
: "SELECT * FROM (SELECT DISTINCT DepCod, DepKorNam, CstStrDte FROM O_CstInf"
|
||||
+ " INNER JOIN M_DepMst ON CstRplDep = DepCod AND CstStrDte BETWEEN DepStrDte AND DepEndDte"
|
||||
+ " WHERE CstComNum = :m AND CstSttFlg = 'G' ORDER BY CstStrDte) WHERE ROWNUM <= 2";
|
||||
command.Parameters.Add(new OracleParameter("m", comNum));
|
||||
var rows = new List<IReadOnlyDictionary<string, string>>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 퇴원과 조회(bzDataInterface.vb:6162-6231) — 내원의 퇴원일시(또는 접수일시)가
|
||||
/// 유효기간 안인 진료과 행. 원문이 P_ComInf 에 M_DepMst 를 <b>날짜 조건만으로</b> 조인하는
|
||||
/// 특이한 모양이라 그대로 옮겼다. anchorColumn 은 ComLevDtm 또는 ComAcpDtm.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> DischargeDepartment(decimal comNum, string depCod, string anchorColumn)
|
||||
{
|
||||
if (anchorColumn is not ("ComLevDtm" or "ComAcpDtm"))
|
||||
{
|
||||
throw new ArgumentOutOfRangeException(nameof(anchorColumn));
|
||||
}
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT DepOfcNam, DepKorNam FROM P_ComInf"
|
||||
+ " INNER JOIN M_DepMst ON DepCod = :d"
|
||||
+ $" AND {anchorColumn} BETWEEN DepStrDte AND DepEndDte || '2359'"
|
||||
+ " WHERE ComNum = :k) WHERE ROWNUM <= 1",
|
||||
("d", depCod), ("k", comNum));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 한 행을 컬럼명 → 값 사전으로. 없으면 빈 사전.
|
||||
/// 컬럼명은 <b>대문자</b>로 담는다 — 오라클이 대문자로 돌려주고, 호출부가 대소문자로 헷갈리면
|
||||
/// 값이 조용히 빈다. 조회는 대소문자 무시 비교로 받는다.
|
||||
/// </summary>
|
||||
private static Dictionary<string, string> Row(OracleConnection connection, string sql,
|
||||
params (string Name, object Value)[] parameters)
|
||||
{
|
||||
var result = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in parameters)
|
||||
{
|
||||
command.Parameters.Add(new OracleParameter(name, value));
|
||||
}
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
return result;
|
||||
}
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
// CHAR 고정폭 잔여 공백을 여기서 한 번 걷는다 — 태그마다 걷으면 빠뜨린다
|
||||
result[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private static string Day(string stamp) => stamp.Length >= 8 ? stamp[..8] : stamp;
|
||||
|
||||
/// <summary>
|
||||
/// 이 내원의 담당의 — <c>OCM_담당의사</c> 계열이 쓴다. 없으면 빈 사전.
|
||||
///
|
||||
/// <b>레거시와 의도적으로 다른 곳이 한 군데 있다.</b>
|
||||
/// 원본 <c>GetCodInfDT(..., With_Join)</c> 는 <c>CodComNum</c> + <c>CodMtiSeq='0'</c> 으로만 거르고
|
||||
/// <b>ORDER BY 가 없는데</b> 호출부는 <c>Rows(0)</c> 을 집는다.
|
||||
/// 이 DB 에서 세어 보니 그런 내원이 <b>25,488건</b>이고 최대 <b>41행</b>이다 —
|
||||
/// 즉 레거시는 그 내원들의 담당의를 확정적으로 찍지 못한다. 재현할 대상이 없으므로
|
||||
/// "그대로 옮긴다"가 성립하지 않는다.
|
||||
///
|
||||
/// 그래서 적용일시로 거르고 최신순 1행을 집는다 — 문맥의 <c>CodInf</c> 와 같은 규칙이다.
|
||||
/// 레거시도 이 시각을 <b>넘기기는 한다</b>(마스터 유효기간 조인에만 쓸 뿐이다).
|
||||
/// 근거는 <c>docs/TAG-PORTABLE-48.md</c>.
|
||||
///
|
||||
/// 담당의 조회는 레거시 <c>GetUidMst</c>(clsCommonLib.vb:10615)를 그대로 옮긴다 —
|
||||
/// 유효기간으로 먼저 찾고, <b>0행이면 코드만으로 다시 찾는다.</b>
|
||||
/// 그 폴백을 빼면 유효기간이 지난 의사가 통째로 빈칸이 된다.
|
||||
/// </summary>
|
||||
/// <param name="moment">진료과 행을 고를 적용일시 12자리</param>
|
||||
/// <param name="doctorDay">담당의 유효기간을 볼 날짜 8자리. 비면 <paramref name="moment"/> 의 앞 8자리</param>
|
||||
/// <param name="codeColumn">진료과 행에서 의사 코드를 집을 컬럼 — 담당의 CodDtrCod, 주치의(전공의) CodStfDtr</param>
|
||||
public IReadOnlyDictionary<string, string> Doctor(decimal comNum, string moment,
|
||||
string? doctorDay = null, string codeColumn = "CodDtrCod")
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var cod = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM P_CodInf WHERE CodComNum = :k AND CodMtiSeq = 0"
|
||||
+ " AND :t BETWEEN CodStrDtm AND CodEndDtm ORDER BY CodStrDtm DESC) WHERE ROWNUM = 1",
|
||||
("k", comNum), ("t", moment));
|
||||
var code = PatientContext.Value(cod, codeColumn);
|
||||
if (code.Length == 0)
|
||||
{
|
||||
return cod;
|
||||
}
|
||||
// 레거시는 입원이면 CodEndDtm 의 날짜로, 외래면 접수일자로 본다 — 호출부가 정해 넘긴다
|
||||
var day = Day((doctorDay ?? string.Empty).Trim().Length > 0 ? doctorDay!.Trim() : moment);
|
||||
|
||||
var uid = Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c"
|
||||
+ " AND :d BETWEEN UidStrDte AND UidEndDte) WHERE ROWNUM = 1",
|
||||
("c", code), ("d", day));
|
||||
return uid.Count > 0
|
||||
? uid
|
||||
// 폴백 — 유효기간 밖이어도 그 코드의 행을 쓴다(레거시 :10641-10644)
|
||||
: Row(connection, "SELECT * FROM (SELECT * FROM M_UidMst WHERE UidCod = :c) WHERE ROWNUM = 1",
|
||||
("c", code));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진료과 마스터 한 행 — <c>OCM_진료과</c>(bzDataInterface.vb:5437-5495)의 조회.
|
||||
/// <c>WHERE DepCod = :d AND :day BETWEEN DepStrDte AND DepEndDte</c>.
|
||||
///
|
||||
/// 유효기간 판정 일자는 레거시가 <b>입원·외래 무관하게 접수일자</b>를 쓴다(:5445 sAcpDte).
|
||||
/// 원본에는 ORDER BY 가 없고 Rows(0) 을 집는다 — 담당의 때와 같은 이유로
|
||||
/// 최신순 1행으로 고정한다(기간이 겹친 이력에서 비결정적이 되는 것을 막는다).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Department(string? depCod, string? day)
|
||||
{
|
||||
var code = (depCod ?? string.Empty).Trim();
|
||||
var date = (day ?? string.Empty).Trim();
|
||||
if (code.Length == 0 || date.Length < 8)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM M_DepMst WHERE TRIM(DepCod) = :d"
|
||||
+ " AND :y BETWEEN DepStrDte AND DepEndDte ORDER BY DepStrDte DESC) WHERE ROWNUM = 1",
|
||||
("d", code), ("y", date[..8]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 요양기관 행 — <c>ETC_요양기관명칭_*</c> 12종(bzDataInterface.vb:15656-16709)의 공통 조회.
|
||||
/// <c>M_DepMst ⨝ M_HspMst(DepHspCod=HspCod)</c> 에서 병원 컬럼들을 뽑는다.
|
||||
/// 진료과 코드로 병원을 찾는 구조다 — 다부지(병원 코드가 다른 진료과) 구성 때문으로 보인다.
|
||||
///
|
||||
/// 레거시는 태그마다 이 조인을 <b>따로</b> 돌린다(12종 = 왕복 12회).
|
||||
/// 컬럼만 다르므로 한 번에 다 뽑아 행으로 돌려준다 — 값 선택은 해석기가 한다.
|
||||
/// 컬럼 9개는 전부 각 태그 본문의 SELECT 에서 확인한 것이다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> HospitalRow(string? depCod, string? day)
|
||||
{
|
||||
var code = (depCod ?? string.Empty).Trim();
|
||||
var date = (day ?? string.Empty).Trim();
|
||||
if (code.Length == 0 || date.Length < 8)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
// HspStrDte(개원일)는 퇴원 계열 태그의 분기 재료(HspCfg.HspStrDte 대응),
|
||||
// ServerToday 는 "오늘" 폴백·재원일수 계산용 서버 날짜 — 단말 시계를 쓰지 않는다
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT HspInsNam, HspOwnNam, HspTelNum, HspFaxNum,"
|
||||
+ " HspKorAdr, HspEngNam, HspEngAdr, HspRgnNum, HspInsNum, HspStrDte,"
|
||||
+ " TO_CHAR(SYSDATE, 'YYYYMMDD') AS ServerToday FROM M_DepMst"
|
||||
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
|
||||
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
|
||||
("d", code), ("y", date[..8]));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 병리판독의(TLAB) 한 명 — <c>ETC_병리판독의사명/전문의번호</c>(bzDataInterface.vb:17318, 17515).
|
||||
/// 원문에 ORDER BY 가 없어 TLAB 의사가 둘이면 이름과 번호가 <b>서로 다른 사람</b>이 될 수 있었다
|
||||
/// (태그마다 따로 조회했으므로). 한 행을 UidCod 순으로 고정해 두 태그가 같은 사람을 가리키게 한다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> PathologyDoctor()
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM M_UidMst WHERE UidDtrYon = 'Y' AND TRIM(UidDepCod) = 'TLAB'"
|
||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 진단검사의(부서그룹 LAB) 한 명 — <c>ETC_진단검사의사명/전문의번호</c>(:17550, 17655).
|
||||
/// 조건은 원문 그대로(의사 + 면허 보유 + 유효기간 + LAB 그룹 부서), 선택 규칙만 고정한다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> LabDoctor()
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT u.* FROM M_UidMst u"
|
||||
+ " INNER JOIN M_DepMst d ON u.UidDepCod = d.DepCod AND d.DepGrpCod = 'LAB'"
|
||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte"
|
||||
+ " WHERE u.UidDtrYon = 'Y' AND u.UidLicNum IS NOT NULL"
|
||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte"
|
||||
+ " ORDER BY u.UidCod) WHERE ROWNUM = 1");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 수술 일정(S_OprInf) 한 행 — <c>OCM_수술일자</c>·<c>ETC_수술일자_*</c> 계열이 쓴다.
|
||||
/// <c>WHERE OprComNum = :c AND OprStt = 'E' ORDER BY OprKey</c> —
|
||||
/// 무인자(Else 갈래)는 ASC(첫 수술), ETC_ 계열과 마지막수술은 DESC('수술실 요청' 주석 그대로).
|
||||
/// GetOprInfDT(E_OprInf 축 큰 조회)와 <b>다른 표</b>다 — 이쪽은 수술 접수 일정이다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> ScheduledSurgery(decimal comNum, bool latestFirst)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT * FROM (SELECT * FROM S_OprInf WHERE OprComNum = :c AND OprStt = 'E'"
|
||||
+ $" ORDER BY OprKey{(latestFirst ? " DESC" : string.Empty)}) WHERE ROWNUM = 1",
|
||||
("c", comNum));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 건물관리번호로 주소 — 레거시 <c>GetZipInfoByBuildingNumber</c>(clsCommonLib.vb:16600).
|
||||
///
|
||||
/// 결합식을 <b>그대로</b> 옮긴다. DECODE 로 빈 조각을 건너뛰고 구분자를 붙이는 규칙이
|
||||
/// 도로명·지번·영문이 각각 다르다 — 손으로 다시 짜면 주소가 미묘하게 달라진다.
|
||||
///
|
||||
/// 없으면 빈 사전. 레거시도 못 찾으면 Nothing 을 돌려주고 호출부가 상세주소만 쓴다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> Address(string? buildingNumber)
|
||||
{
|
||||
var key = (buildingNumber ?? string.Empty).Trim();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return Row(connection,
|
||||
"SELECT 구역번호, 우편번호"
|
||||
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
|
||||
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
|
||||
+ " || DECODE(TRIM(도로명), NULL, '', TRIM(도로명) || ' ')"
|
||||
+ " || DECODE(지하여부, 1, '지하 ', '')"
|
||||
+ " || DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번))"
|
||||
+ " || DECODE(TRIM(건물번호부번), NULL, ' ', 0, ' ', '-' || TRIM(건물번호부번) || ' ')"
|
||||
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 도로명주소"
|
||||
+ ", TRIM(시도) || ' ' || DECODE(TRIM(시군구), NULL, '', TRIM(시군구) || ' ')"
|
||||
+ " || DECODE(TRIM(읍면), NULL, '', TRIM(읍면) || ' ')"
|
||||
+ " || DECODE(TRIM(법정동명), NULL, '', TRIM(법정동명) || ' ')"
|
||||
+ " || DECODE(TRIM(리명), NULL, '', TRIM(리명) || ' ')"
|
||||
+ " || DECODE(산여부, 1, ' 산 ', '')"
|
||||
+ " || DECODE(TRIM(지번본번), NULL, '', TRIM(지번본번))"
|
||||
+ " || DECODE(TRIM(지번부번), NULL, ' ', 0, ' ', '-' || TRIM(지번부번) || ' ')"
|
||||
+ " || DECODE(TRIM(시군구용건물명), NULL, '', TRIM(시군구용건물명) || '') AS 지번주소"
|
||||
+ ", DECODE(TRIM(건물번호본번), NULL, '', TRIM(건물번호본번) || ' ')"
|
||||
+ " || DECODE(TRIM(도로명영문), NULL, '', TRIM(도로명영문) || ', ')"
|
||||
+ " || DECODE(TRIM(읍면영문), NULL, '', TRIM(읍면영문) || ', ')"
|
||||
+ " || DECODE(TRIM(시군구영문), NULL, '', TRIM(시군구영문) || ', ')"
|
||||
+ " || TRIM(시도영문) AS 영문도로명주소"
|
||||
+ " FROM M_ZipMst WHERE 건물관리번호 = :b", ("b", key));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,925 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 환자 부가정보 태그들의 자기 SQL — 레거시 <c>bzDataInterface.vb</c> 의 함수 본문에서
|
||||
/// 기계적으로 옮겼다(실제생년월일 :226 · 국적 :639 · 장애등급 :687 · 건보세대주명 :732 ·
|
||||
/// 건보_급여_세대주명 :780 · 협력업체 :837 · 보호자연락처 :881).
|
||||
///
|
||||
/// <b>차트번호 매칭.</b> 레거시는 <c>PatChtNum</c>(CHAR 10) 원문 — 패딩 포함 — 을 바인드한다.
|
||||
/// 우리 문맥은 행을 저장할 때 Trim 하므로(PatientContextStore.Row), CHAR 컬럼은
|
||||
/// <c>RPAD(:c,10)</c> 으로 패딩을 되살려 비교한다(인덱스도 산다). P_pdsoInf 만 VARCHAR2 라
|
||||
/// 저장값의 패딩 여부를 확정할 수 없어 두 형태 모두 본다(IN).
|
||||
///
|
||||
/// <b>Rows(0) 결정 규칙.</b> 원문은 전부 ORDER BY 없는 첫 행이다. 여기서는
|
||||
/// 담당의 때 정한 규칙대로 정렬을 명시해 한 행으로 고정한다 — 각 메서드 주석에 근거를 남겼다.
|
||||
/// </summary>
|
||||
public sealed class PatientExtraStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PatientExtraStore(string connectionString)
|
||||
{
|
||||
this.connectionString = connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 실제생년월일(P_PatEtcInf.PatEtcBirDte, YYYYMMDD 원문) — 없으면 빈 문자열.
|
||||
/// 원문은 SELECT * 의 Rows(0) 인데 이 표는 차트번호가 사실상 유일키(환자 부가정보 마스터)다.
|
||||
/// 만에 하나 다중이면 갱신일시 최신 행 — 마지막으로 저장한 값이 현재 값이라는 뜻이다.
|
||||
/// </summary>
|
||||
public string ActualBirthDate(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT PatEtcBirDte FROM (SELECT PatEtcBirDte FROM P_PatEtcInf"
|
||||
+ " WHERE PatEtcChtNum = RPAD(:c, 10) ORDER BY PatEtcUpdDtf DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>보호자연락처(P_PatEtcInf.PatEtcGrdnPhn) — 원문의 IS NOT NULL 조건 그대로</summary>
|
||||
public string GuardianPhone(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT PatEtcGrdnPhn FROM (SELECT PatEtcGrdnPhn FROM P_PatEtcInf"
|
||||
+ " WHERE PatEtcChtNum = RPAD(:c, 10) AND PatEtcGrdnPhn IS NOT NULL"
|
||||
+ " ORDER BY PatEtcUpdDtf DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 국적 한글명 — P_PatInf.PatNatCod 를 M_DtlMst(NATCOD) 로 푼다.
|
||||
/// P_PatInf 는 차트번호당 1행, 조인 키(DtlTblCod+DtlCod)도 유일이라 정렬이 필요 없다.
|
||||
/// </summary>
|
||||
public string Nationality(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT DtlCodNam FROM P_PatInf"
|
||||
+ " INNER JOIN M_DtlMst ON DtlCod = PatNatCod AND DtlTblCod = 'NATCOD'"
|
||||
+ " WHERE PatChtNum = RPAD(:c, 10)",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 장애등급 — P_pdsoInf(PdsoFlg='E') 의 PdsoGrd. 호출부가 두 번째 글자를 뽑는다(레거시 그대로).
|
||||
///
|
||||
/// 원문(:701-710)은 P_ComInf 를 INNER JOIN 해 행을 내원 수만큼 곱해 놓고 RowNum 1 로
|
||||
/// 접는다 — 조인의 실효는 "내원이 있는 환자인가" 확인뿐이라 EXISTS 로 바꿨다(의미 동일).
|
||||
/// ORDER BY 없는 RowNum 1 은 등급 변경 이력이 있으면 임의 행이다 — 주석의 의도
|
||||
/// ("현재 작성일 기준 등급", 2017-10-25 푸르메 요청)에 맞게 시작일 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string DisabilityGrade(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT PdsoGrd FROM (SELECT PdsoGrd FROM P_pdsoInf"
|
||||
+ " WHERE PdsoChtNum IN (:c, RPAD(:c, 10)) AND PdsoFlg = 'E'"
|
||||
+ " AND EXISTS (SELECT 1 FROM P_ComInf WHERE ComChtNum = RPAD(:c, 10))"
|
||||
+ " ORDER BY PdsoStrDte DESC, PdsoSeq DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 건보 세대주명 — P_PISINF(PisInsCod='1') 를 이 내원의 자격(P_CoiInf.CoiInsSeq)과 맞춘다.
|
||||
/// 자격 이력이 여러 건이면 원문은 임의 행 — 시작일 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string HouseholderName(string chtNum, decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT Pis1SedaejuNm FROM (SELECT Pis1SedaejuNm FROM P_PISINF"
|
||||
+ " INNER JOIN P_CoiInf ON CoiInsSeq = PisInsSeq AND CoiComNum = :m"
|
||||
+ " WHERE PisChtNum = RPAD(:c, 10) AND PisInsCod = '1'"
|
||||
+ " ORDER BY PisStrDte DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum), ("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 세대주명(건보·급여) — 문맥 자격의 (보험코드, 순번)으로 P_PISINF 를 직접 짚는다.
|
||||
/// 레거시(:780-)는 참조 내원이 있으면 그쪽 자격을 쓰지만 미리보기는 참조를 설정하지
|
||||
/// 않으므로(TestPatientSetting) 자기 내원 갈래만 옮겼다.
|
||||
/// </summary>
|
||||
public string HouseholderNameByInsurance(string chtNum, string insSeq, string insCod)
|
||||
=> Scalar(
|
||||
"SELECT Pis1SedaejuNm FROM (SELECT Pis1SedaejuNm FROM P_PISINF"
|
||||
+ " WHERE PisChtNum = RPAD(:c, 10) AND PisInsSeq = :s AND PisInsCod = :d"
|
||||
+ " ORDER BY PisStrDte DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("s", insSeq), ("d", insCod));
|
||||
|
||||
/// <summary>
|
||||
/// 협력업체 한글명 — 내원행의 ComCoopHsp 를 M_DtlMst 로 푼다.
|
||||
/// 원문(:850-854)은 DtlTblCod 조건 없이 DtlCod 만으로 조인한다 — 같은 코드가 다른
|
||||
/// 코드표에도 있으면 다중행이다. 레거시 조인을 그대로 두되 테이블코드 순 첫 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string Cooperator(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT DtlCodNam FROM (SELECT DtlCodNam FROM P_ComInf"
|
||||
+ " INNER JOIN M_DtlMst ON ComCoopHsp = DtlCod"
|
||||
+ " WHERE ComNum = :m ORDER BY DtlTblCod) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 퇴원 이후 다음 외래 방문(OCM_FollowUp, :1028) — 지금보다 뒤의 취소 아닌 내원 중
|
||||
/// 가장 가까운 것의 접수일시 12자리. 원문의 ROWNUM 은 ORDER BY 보다 먼저 걸려
|
||||
/// 임의 행이었다 — 인라인뷰로 정렬을 먼저 걸어 "가장 가까운 미래 내원"으로 고정한다.
|
||||
/// "지금"은 서버 시각이다(레거시 SystemDateTime 과 같은 의미).
|
||||
/// </summary>
|
||||
public string FollowUp(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10)"
|
||||
+ " AND ComAcpDtm > TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')"
|
||||
+ " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 외출·외박 구간의 한쪽 끝(OCM_외출외박신청일/종료일, :1883/:1936) — 오늘을 포함하는
|
||||
/// 외박 허용(CowSlpOut='Y') 병실 행의 CowStrDtm/CowEndDtm.
|
||||
/// <b>0행이면 서버 현재시각을 돌려준다</b> — 특이하지만 레거시가 그렇다
|
||||
/// (기록이 없으면 "지금"이 종이에 찍힌다). P_RblInf 곱으로 다중행이면 원문은
|
||||
/// 임의 행이었다 — 시작일시 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string LeaveOfAbsence(decimal comNum, bool endSide)
|
||||
{
|
||||
var column = endSide ? "CowEndDtm" : "CowStrDtm";
|
||||
// "오늘"은 서버 날짜다(레거시 SystemDate 와 같은 의미) — 바인드 대신 SYSDATE 로 쓴다
|
||||
return Scalar(
|
||||
"SELECT NVL(MAX(v), TO_CHAR(SYSDATE, 'YYYYMMDDHH24MI')) FROM ("
|
||||
+ $"SELECT v FROM (SELECT {column} v FROM P_CoiInf"
|
||||
+ " INNER JOIN P_ComInf ON ComNum = CoiComNum AND ComPatTyp = 'I' AND ComAcpStt <> 'IC'"
|
||||
+ " INNER JOIN P_CowInf ON CowComNum = CoiComNum"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' BETWEEN CowStrDtm AND CowEndDtm"
|
||||
+ " INNER JOIN P_RblInf ON RblComNum = CoiComNum AND CoiMtiSeq = RblMtiSeq"
|
||||
+ " AND RblStrDte BETWEEN CoiStrDte AND CoiEndDte"
|
||||
+ " WHERE CoiComNum = :m AND RblDtrCod = '-' AND RblNxtNum = 0 AND RblIncTyp = 'M'"
|
||||
+ " AND CowSlpOut = 'Y'"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN CoiStrDte AND CoiEndDte"
|
||||
+ " ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1)",
|
||||
("m", comNum));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 최초 내원일(OCM_최초내원일, :1988 Case Else) — 이 내원 이전의 취소 아닌 재진(ComNfsTyp='2')
|
||||
/// 중 가장 이른 접수일시. 원문이 인라인뷰 안에 ORDER BY 를 걸어 이 쿼리는 원래 결정적이다.
|
||||
/// P_CodInf 조인은 원문 그대로(진료 행이 있는 내원만) — 곱행이 생겨도 값이 같은 컬럼이라 무해.
|
||||
/// KIMEYE 분기(초재진 무구분, ComNfsTyp 우선 정렬)는 옮기지 않았다.
|
||||
/// </summary>
|
||||
public string FirstVisit(string chtNum, string acpDtm)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
|
||||
+ " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'"
|
||||
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("a", acpDtm));
|
||||
|
||||
/// <summary>
|
||||
/// 진료과별 초진일(OCM_초진일_발병일 :2040 · OCM_신환_초진일 :2138) — 최초 내원일에
|
||||
/// 진료과 앞 2자 조건이 붙는다. 신환 포함이면 ComNfsTyp IN ('1','2'), 아니면 '2' 만.
|
||||
/// </summary>
|
||||
public string FirstVisitByDepartment(string chtNum, string acpDtm, string depCod2, bool includeNew)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
|
||||
+ (includeNew ? " AND ComAcpStt <> 'OC' AND ComNfsTyp IN ('1','2')"
|
||||
: " AND ComAcpStt <> 'OC' AND ComNfsTyp = '2'")
|
||||
+ " AND SUBSTR(CodDepCod, 1, 2) = :p"
|
||||
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("a", acpDtm), ("p", depCod2));
|
||||
|
||||
/// <summary>
|
||||
/// 낮병동 입원일자(OCM_입원일자_낮병동, :7260) — 이 내원의 접수일시를 포함하는
|
||||
/// 낮병동 신청(P_DcpInf, DcpAcpStt='A') 구간의 시작일. 원문 Rows(0) 은 구간이
|
||||
/// 겹치면 임의 행이었다 — 시작일 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string DayCareAdmissionDate(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT DcpStrDte v FROM P_ComInf"
|
||||
+ " INNER JOIN P_DcpInf ON ComChtNum = DcpChtNum"
|
||||
+ " AND ComAcpDtm BETWEEN DcpStrDte AND DcpEndDte || '2359'"
|
||||
+ " AND DcpAcpStt = 'A'"
|
||||
+ " WHERE ComNum = :m AND ComAcpStt <> 'OC' AND ComDayCar = 'Y'"
|
||||
+ " ORDER BY DcpStrDte DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 혈액형(OCM_혈액형, :11783) — S_BlpInf.BlpAboTyp. 원문 Rows(0) 에 ORDER BY 가 없다 —
|
||||
/// 갱신일시 최신 행으로 고정한다(마지막 판정이 현재 혈액형이다).
|
||||
/// BlpChtNum 은 VARCHAR2 라 저장 형태를 확정할 수 없어 두 형태 모두 본다.
|
||||
/// </summary>
|
||||
public string BloodType(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT BlpAboTyp v FROM S_BlpInf"
|
||||
+ " WHERE BlpChtNum IN (:c, RPAD(:c, 10))"
|
||||
+ " ORDER BY BlpUpdDtm DESC NULLS LAST) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 최종 월경일(OCM_LMP, :11813) — O_PrgInf.PrgLmpDte. 원문 Rows(0) 에 ORDER BY 가 없다 —
|
||||
/// 같은 표를 읽는 임신주기(:11845)가 갱신일시 최신순을 쓰므로 그 정렬로 고정한다.
|
||||
/// </summary>
|
||||
public string Lmp(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT PrgLmpDte v FROM O_PrgInf"
|
||||
+ " WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 발병일(OCM_발병일, :8125) — 대표 주상병(OkdSeq=1, OkdStt='E')과 오늘 유효한
|
||||
/// 발병 기록(O_OnsInf)의 시작일. 원문 Rows(0) 에 ORDER BY 가 없다 —
|
||||
/// 발병 시작일 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string OnsetDate(string chtNum, decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT OnsStrDte v FROM O_OkdInf"
|
||||
+ " INNER JOIN O_OnsInf ON OkdKcdCod = OnsKcdCod"
|
||||
+ " AND OnsChtNum = RPAD(:c, 10)"
|
||||
+ " AND (OnsDelDtf IS NULL OR OnsDelDtf = ' ')"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN OnsStrDte AND OnsEndDte"
|
||||
+ " WHERE OkdComNum = :m AND OkdSeq = 1 AND OkdStt = 'E'"
|
||||
+ " ORDER BY OnsStrDte DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 당일 식이(OCM_당일식이, :12620) — 오늘(SYSDATE) 입원 식이의 아침/점심/저녁 PIVOT 행.
|
||||
/// PIVOT 이 내원 단위로 접으므로 결과는 최대 1행 — 원문 그대로 결정적이다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> TodayMeals(decimal comNum)
|
||||
=> Row(
|
||||
"WITH ComIml AS (SELECT A01.ImlMgdTyp MgdTyp, B01.MgdKorNam MgdNam, A01.ImlComNum"
|
||||
+ " FROM O_ImlInf A01 INNER JOIN M_MgdMst B01 ON MgdCod = ImlMgdCod"
|
||||
+ " WHERE ImlDte = TO_CHAR(SYSDATE, 'YYYYMMDD') AND ImlPatTyp = 'I' AND ImlComNum = :m)"
|
||||
+ " SELECT * FROM ComIml"
|
||||
+ " PIVOT (MAX(MgdNam) AS MgdTyp FOR MgdTyp IN ('B' 아침, 'L' 점심, 'D' 저녁))",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 알러지(OCM_알러지·_조영제·_약물·_음식·_기타, :14941-15140) — 오늘 유효한 코드마스터
|
||||
/// (ALLERGY)에 매칭되는 행을 SQL LISTAGG 로 CRLF 결합. category 가 null 이면 전체.
|
||||
/// 집계 쿼리라 항상 1행 — 매칭 0건이면 NULL → 빈 문자열이다(원문과 같은 귀결).
|
||||
/// </summary>
|
||||
public string Allergy(string chtNum, string? category)
|
||||
{
|
||||
var filter = category is null ? string.Empty : " AND AlgAlgCtg = :g";
|
||||
var binds = category is null
|
||||
? new (string, object)[] { ("c", chtNum) }
|
||||
: new (string, object)[] { ("c", chtNum), ("g", category) };
|
||||
return Scalar(
|
||||
"SELECT LISTAGG(AlgAlgCtg || '-' || CASE WHEN NVL(AlgAlgSpm, ' ') <> ' '"
|
||||
+ " THEN AlgAlgTyp || ' (증상 : ' || AlgAlgSpm || ')' ELSE AlgAlgTyp END,"
|
||||
+ " CHR(13) || CHR(10)) WITHIN GROUP (ORDER BY DtsDtlCod, DtsDspSeq)"
|
||||
+ " FROM S_AlgInf INNER JOIN M_DtsMst ON DtsTblCod = 'ALLERGY'"
|
||||
+ " AND AlgAlgTyp = DtsCodNam"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
|
||||
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
|
||||
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D')" + filter,
|
||||
binds);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 알러지 원장 행들(OCM_알러지_List, :15142) — 코드마스터 조인 없이 전부,
|
||||
/// 갱신일시 오름차순(원문 ORDER BY 그대로). 결합은 해석기가 원문 결함까지 흉내낸다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> AllergyRows(string chtNum)
|
||||
=> Rows(
|
||||
"SELECT AlgAlgCtg, AlgAlgTyp, AlgAlgSpm FROM S_AlgInf"
|
||||
+ " WHERE AlgChtNum IN (:c, RPAD(:c, 10))"
|
||||
+ " AND (AlgDelFlg IS NULL OR AlgDelFlg <> 'D') ORDER BY AlgUpdDtf ASC",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 외출 예상 일시(OCM_외출예상시작/종료일시, :15197/:15231) — 오늘 0시 이후 시작하는
|
||||
/// 외출·외박 신청 중 가장 이른 것. 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다.
|
||||
/// 두 태그가 같은 행의 양 끝을 읽는다(정렬 키는 시작 쪽).
|
||||
/// </summary>
|
||||
public string ExpectedOuting(string chtNum, bool endSide)
|
||||
=> Scalar(
|
||||
"SELECT " + (endSide ? "NVL(OutAdmEndDtm, OutAdmExpEndDtm)" : "NVL(OutAdmStrDtm, OutAdmExpStrDtm)")
|
||||
+ " FROM (SELECT * FROM O_OutAdmInf"
|
||||
+ " WHERE OutAdmChtNum IN (:c, RPAD(:c, 10))"
|
||||
+ " AND OutAdmStrDtm >= TO_CHAR(SYSDATE, 'YYYYMMDD') || '0000'"
|
||||
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm)) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 건강보험증번호(ETC_건강보험증번호, :15269) — 건보(PisInsCod=1) 자격의 최신 순번 행.
|
||||
/// 원문의 숫자 리터럴 비교(PisInsCod = 1)와 TO_NUMBER 정렬을 그대로 옮겼다.
|
||||
/// </summary>
|
||||
public string InsuranceCertNumber(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT Pis1AsylmSym v FROM P_PisInf"
|
||||
+ " WHERE PisInsCod = 1 AND PisChtNum = RPAD(:c, 10)"
|
||||
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>재해발생일(ETC_재해발생일, :15323) — 산재(PisInsCod=4) 자격의 최신 순번 행. YYYYMMDD 원문 그대로</summary>
|
||||
public string AccidentDate(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT Pis4AcdDte v FROM P_PisInf"
|
||||
+ " WHERE PisInsCod = 4 AND PisChtNum = RPAD(:c, 10)"
|
||||
+ " ORDER BY TO_NUMBER(PisInsSeq) DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 산재지정번호(ETC_산재지정번호, :15727) — 진료과로 병원을 찾아 HspWrkNum.
|
||||
/// 요양기관 행과 같은 조인이라 다중이면 한 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string IndustrialAccidentNumber(string depCod, string day)
|
||||
=> depCod.Trim().Length == 0 || day.Trim().Length < 8 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT HspWrkNum v FROM M_DepMst"
|
||||
+ " INNER JOIN M_HspMst ON DepHspCod = HspCod AND :y BETWEEN HspStrDte AND HspEndDte"
|
||||
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte) WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()), ("y", day.Trim()[..8]));
|
||||
|
||||
/// <summary>
|
||||
/// 감염 분류·정보(OCM_감염분류_감염정보, :14084) — 오늘 유효한 감염 기록을
|
||||
/// "분류 앞 2자-상세명" 으로 LISTAGG CRLF 결합. 집계라 항상 1행이다.
|
||||
/// (OCM_감염정보 :14122 는 원문 SQL 의 바인드 콜론이 빠져 항상 오류→빈 값 —
|
||||
/// 해석기가 사유로 말하고 여기서는 구현하지 않는다.)
|
||||
/// </summary>
|
||||
public string InfectionInfo(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT LISTAGG(SUBSTR(DtlCodNam, 1, 2) || '-' || DtsCodNam, CHR(13) || CHR(10))"
|
||||
+ " WITHIN GROUP (ORDER BY DtsDspSeq)"
|
||||
+ " FROM S_IcnInf"
|
||||
+ " INNER JOIN M_DtlMst ON DtlCod = IcnIcnCod AND DtlTblCod = 'INFECTION'"
|
||||
+ " INNER JOIN M_DtsMst ON DtsCod = IcnDtlCod"
|
||||
+ " WHERE IcnChtNum IN (:c, RPAD(:c, 10))"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN IcnStrDte AND IcnEndDte",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 퇴원분석 수술·처치명 목록(OCM_퇴원분석_수술처치명, :13920) — "코드 - 이름" 을
|
||||
/// 순번 순으로. 결합은 해석기가 한다(원문은 AppendLine 이라 마지막 뒤에도 개행이 남는다).
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> DischargeAnalysisOperations(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT DssOpnCod || ' - ' || DssOpnNam AS OpnNam FROM S_DssOpn"
|
||||
+ " WHERE DssComNum = :m"
|
||||
+ " AND (DssOpnCod IS NOT NULL AND DssOpnCod <> ' ')"
|
||||
+ " AND (DssOpnNam IS NOT NULL AND DssOpnNam <> ' ')"
|
||||
+ " ORDER BY DssOpnSeq",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 병실 인실(OCM_병실인실, :7065) — 입실 이력과 병실 마스터의 배정 침상 수.
|
||||
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 전실했으면 임의 병실이었다. 최신 입실로 고정한다.
|
||||
/// </summary>
|
||||
public string RoomBedCount(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT RomAsgBed v FROM P_CowInf"
|
||||
+ " INNER JOIN M_RomMst ON RomCod = CowRomCod"
|
||||
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
|
||||
+ " WHERE CowComNum = :m ORDER BY CowStrDtm DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 병실 차액 구분(OCM_병실차액, :7103) — 첫 입실 병실 등급의 건보 산정 구분(SgaHthIns).
|
||||
/// 원문이 CowStrDtm 오름차순 첫 행이라 결정적이다 — 그대로 옮겼다.
|
||||
/// </summary>
|
||||
public string RoomExtraCharge(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT SgaHthIns v FROM P_ComInf"
|
||||
+ " INNER JOIN P_CowInf ON CowComNum = ComNum"
|
||||
+ " INNER JOIN M_RomMst ON CowRomCod = RomCod"
|
||||
+ " AND CowStrDtm BETWEEN RomStrDte AND RomEndDte || '2359'"
|
||||
+ " INNER JOIN M_RgdMst ON CowGrdCod = RgdCod"
|
||||
+ " AND CowStrDtm BETWEEN RgdStrDte AND RgdEndDte || '2359'"
|
||||
+ " INNER JOIN M_SgaMst ON RgdAmtCod = SgaCod"
|
||||
+ " AND CowStrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
|
||||
+ " WHERE ComNum = :m ORDER BY CowStrDtm ASC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 계산유형 이름(OCM_계산유형, :5133) — 자격의 보험·계산 코드로 M_CalMst.CalNam.
|
||||
/// 원문 Rows(0) 에 ORDER BY 가 없다 — 시작일 최신 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string CalculationTypeName(string insCod, string calCod, string strDte)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT CalNam v FROM M_CalMst"
|
||||
+ " WHERE CalInsCod = :i AND CalCod = :l AND :d BETWEEN CalStrDte AND CalEndDte"
|
||||
+ " ORDER BY CalStrDte DESC) WHERE ROWNUM = 1",
|
||||
("i", insCod), ("l", calCod), ("d", strDte));
|
||||
|
||||
/// <summary>보험유형 이름(OCM_보험유형, :5186) — M_InsMst.InsNam. 같은 규칙으로 고정</summary>
|
||||
public string InsuranceTypeName(string insCod, string strDte)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT InsNam v FROM M_InsMst"
|
||||
+ " WHERE InsCod = :i AND :d BETWEEN InsStrDte AND InsEndDte"
|
||||
+ " ORDER BY InsStrDte DESC) WHERE ROWNUM = 1",
|
||||
("i", insCod), ("d", strDte));
|
||||
|
||||
/// <summary>
|
||||
/// 수납일시(OCM_수납일시, :5287) — 자격에 걸린 수납청구(P_RblInf, RblCalNxt=0)의
|
||||
/// 수납(P_RcpInf) 갱신일시 12자리. 원문은 두 쿼리 모두 ORDER BY 없는 Rows(0) —
|
||||
/// 청구·수납 다건이면 임의였다. 최신 청구의 최신 수납으로 고정하고 한 왕복으로 접었다.
|
||||
/// </summary>
|
||||
public string ReceiptStamp(decimal comNum, string mtiSeq, string strDte)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT RcpUpdDtf v FROM P_RcpInf WHERE RcpRblNum IN ("
|
||||
+ " SELECT n FROM (SELECT RblNum n FROM P_RblInf"
|
||||
+ " WHERE RblComNum = :m AND RblMtiSeq = :s AND RblStrDte = :d AND RblCalNxt = 0"
|
||||
+ " ORDER BY RblNum DESC) WHERE ROWNUM = 1)"
|
||||
+ " ORDER BY RcpUpdDtf DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum), ("s", mtiSeq), ("d", strDte));
|
||||
|
||||
/// <summary>
|
||||
/// 최신 퇴원약 오더 일시(OCM_진료기간 SRCH 갈래, :2299-2318) — 인라인뷰 정렬이
|
||||
/// 원문부터 있어 결정적이다. 필터(처방의약·숨김 제외 등)는 원문 그대로.
|
||||
/// </summary>
|
||||
public string DischargeMedicationOrderDate(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT OdrDtm v FROM O_OdrInf"
|
||||
+ " WHERE OdrComNum = :m AND OdrStt = 'OE'"
|
||||
+ " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
|
||||
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
|
||||
+ " AND OdrGrpSub = 0"
|
||||
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
|
||||
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
|
||||
+ " ORDER BY OdrDtm DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 외래 진료기간 시작(OCM_진료기간 외래 갈래, :2325-2345) — 이 내원 이하의 취소 아닌
|
||||
/// 최초 접수일시. OCM_최초내원일과 달리 진료행 조인·재진 필터가 없다(원문 그대로 —
|
||||
/// ComNfsTyp='N' 조건은 주석 처리돼 있어 옮기지 않는다).
|
||||
/// </summary>
|
||||
public string TreatmentPeriodStart(string chtNum, string acpDtm)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a AND ComAcpStt <> 'OC'"
|
||||
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("a", acpDtm));
|
||||
|
||||
/// <summary>외래 진료기간 끝(:2352-2372) — 시작 이후 오늘(2359)까지의 최신 접수일시</summary>
|
||||
public string TreatmentPeriodEnd(string chtNum, string strDte)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm >= :s"
|
||||
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE, 'YYYYMMDD') || '2359' AND ComAcpStt <> 'OC'"
|
||||
+ " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("s", strDte));
|
||||
|
||||
/// <summary>
|
||||
/// 응급증상 오더의 산정 여부(OCM_응급증상여부_YES/NO, :12748/:12830) — 응급 내원에
|
||||
/// 걸린 응급의료관리료 오더 중 한 행의 OdrInsYon. 행이 없으면 null(레거시 $NoCheck$ —
|
||||
/// 체크 상태를 정하지 않는다). 원문 Rows(0) 에 ORDER BY 가 없다 — 오더일시 최신으로
|
||||
/// 고정한다. 코드 목록은 레거시 GetDtlMst("EMGEMGCOD") 그대로: 코드표에 있으면 그것,
|
||||
/// 없으면 하드코딩 6종(레거시 폴백).
|
||||
/// </summary>
|
||||
public string? EmergencySymptomInsYon(decimal comNum)
|
||||
{
|
||||
var codes = Rows("SELECT DtlCod FROM M_DtlMst WHERE DtlTblCod = 'EMGEMGCOD' ORDER BY DtlCod")
|
||||
.Select(r => r.TryGetValue("DtlCod", out var v) ? v : string.Empty)
|
||||
.Where(v => v.Length > 0)
|
||||
.ToArray();
|
||||
if (codes.Length == 0)
|
||||
{
|
||||
codes = new[] { "V1200", "V1300", "V1400", "V1500", "AC103", "AC105" };
|
||||
}
|
||||
var placeholders = string.Join(", ", codes.Select((_, i) => $":g{i}"));
|
||||
var binds = new List<(string, object)> { ("m", comNum) };
|
||||
for (var i = 0; i < codes.Length; i++)
|
||||
{
|
||||
binds.Add(($"g{i}", codes[i]));
|
||||
}
|
||||
var value = Scalar(
|
||||
"SELECT v FROM (SELECT OdrInsYon v FROM S_EmgInf OCMEMG"
|
||||
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
|
||||
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
|
||||
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
|
||||
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
|
||||
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
|
||||
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
|
||||
+ " WHERE OCMEMG.EmgComNum = :m"
|
||||
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
|
||||
+ $" AND OdrElcCod IN ({placeholders})"
|
||||
+ " ORDER BY OdrDtm DESC, OdrKey DESC) WHERE ROWNUM = 1",
|
||||
binds.ToArray());
|
||||
// 빈 문자열은 "행은 있는데 값이 NULL"과 "행이 없다"를 못 가른다 — COUNT 로 가른다
|
||||
if (value.Length > 0)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
var count = Scalar(
|
||||
"SELECT COUNT(*) FROM S_EmgInf OCMEMG"
|
||||
+ " LEFT JOIN S_EmgIcmInf ICMEMG ON OCMEMG.EmgComNum = ICMEMG.EmgOcmNum"
|
||||
+ " INNER JOIN O_OdrInf ON CASE WHEN NVL(ICMEMG.EmgComNum, 0) > 0"
|
||||
+ " THEN ICMEMG.EmgComNum ELSE OCMEMG.EmgComNum END = OdrComNum"
|
||||
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
|
||||
+ " INNER JOIN M_SgaMst ON OdrCod = SgaCod"
|
||||
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
|
||||
+ " WHERE OCMEMG.EmgComNum = :m"
|
||||
+ " AND OdrStt = 'OE' AND (OdrHidden IS NULL OR OdrHidden <> 1)"
|
||||
+ $" AND OdrElcCod IN ({placeholders})",
|
||||
binds.ToArray());
|
||||
return count == "0" ? null : string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 전과 내역(OCM_전과내역, :1103) — 진료 행 전부를 과명·구간과 함께.
|
||||
/// 원문 조인 그대로: 과 조인이 담당의 조인의 병원 코드에 종속돼(DepHspCod=Usr.UidHspCod)
|
||||
/// 의사 매칭이 빠지면 과명도 빈다. 정렬(CodStrDtm)이 원문부터 있어 결정적이다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> TransferHistory(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT DepKorNam AS DepNam, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
|
||||
+ " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
|
||||
+ " AND Cod.CodEndDtm BETWEEN Usr.UidStrDte AND Usr.UidEndDte || '2359'"
|
||||
+ " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
|
||||
+ " AND DepWrkTyp = 'D' AND Cod.CodEndDtm BETWEEN DepStrDte AND DepEndDte || '2359'"
|
||||
+ " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 청구 진료과목 이름(OCM_진단서청구진료과목_로그인, :4790) — 진료과의 청구코드
|
||||
/// (DepBilCod)를 코드표(BDPTBL)로 푼다. 기준일은 오늘(원문 SystemDate).
|
||||
/// 원문 Rows(0) 무가드 — 한 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string BillingDepartmentName(string depCod)
|
||||
=> depCod.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT DtlCodNam v FROM M_DepMst"
|
||||
+ " INNER JOIN M_DtlMst ON DepBilCod = DtlCod AND DtlTblCod = 'BDPTBL'"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
|
||||
+ " WHERE TRIM(DepCod) = :d"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte)"
|
||||
+ " WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// 당뇨식 칼로리(OCM_당뇨식칼로리_List, :3569) — 당뇨식 중 최신 식이일의 칼로리 1건.
|
||||
/// 원문부터 인라인뷰 정렬 + ROWNUM 이라 결정적이다(식이일 동률만 모호).
|
||||
/// </summary>
|
||||
public string DiabetesMealCalorie(string chtNum)
|
||||
=> Scalar(
|
||||
"SELECT ImlCal FROM (SELECT ImlCal FROM O_ImlInf"
|
||||
+ " INNER JOIN P_ComInf ON ComNum = ImlComNum AND ComChtNum = RPAD(:c, 10)"
|
||||
+ " INNER JOIN M_MgdMst ON MgdCod = ImlMgdCod"
|
||||
+ " WHERE MgdKorNam LIKE '%당뇨%' AND TO_NUMBER(ImlCal) > 0"
|
||||
+ " ORDER BY ImlDte DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 수술 신청의 수술일자(OCM_수술일자 Case Else :2977 · OCM_수술일자_마지막수술 :2999) —
|
||||
/// 확정(OprStt='E') 수술의 OprDte. 본체는 첫 수술(OprKey ASC), 마지막수술은 최신
|
||||
/// (OprKey DESC). PURME(마취 오더)·BSGH/HIMCHAN/GJHNSS(DESC 뒤집기) 분기는 미이식.
|
||||
/// </summary>
|
||||
public string SurgeryDate(decimal comNum, bool latestFirst)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT OprDte v FROM S_OprInf"
|
||||
+ " WHERE OprComNum = :m AND OprStt = 'E'"
|
||||
+ $" ORDER BY OprKey {(latestFirst ? "DESC" : "ASC")}) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 외래 실진료일 목록(OCM_외래실진료일 :1217 · _과목별 :1503) — 신환('1') 첫 접수
|
||||
/// (없으면 전체 첫 접수)부터 마지막 접수까지, 지정 진료과의 접수일시 전부(시간순).
|
||||
/// exactDepartment=false 면 과 앞 2자 매칭(본체), true 면 전체 코드 일치(과목별).
|
||||
/// 원문 세 선행 쿼리 모두 인라인뷰 정렬 + ROWNUM 이라 결정적이다.
|
||||
/// 본체 원문의 종료일 쿼리는 차트번호를 문자열 연결로 넣지만 값이 같아 바인드로 정상화했다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> OutpatientVisitDays(string chtNum, string acpDtm, string depCod, bool exactDepartment)
|
||||
{
|
||||
var first = Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpDtm <= :a"
|
||||
+ " AND ComAcpStt <> 'OC' AND ComNfsTyp = '1'"
|
||||
+ " ORDER BY ComAcpDtm) WHERE ROWNUM = 1",
|
||||
("c", chtNum), ("a", acpDtm));
|
||||
if (first.Length < 8)
|
||||
{
|
||||
first = Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'"
|
||||
+ " ORDER BY ComAcpDtm ASC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
}
|
||||
if (first.Length < 8)
|
||||
{
|
||||
return Array.Empty<string>();
|
||||
}
|
||||
var last = Scalar(
|
||||
"SELECT v FROM (SELECT ComAcpDtm v FROM P_ComInf"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10) AND ComAcpStt <> 'OC'"
|
||||
+ " ORDER BY ComAcpDtm DESC) WHERE ROWNUM = 1",
|
||||
("c", chtNum));
|
||||
var depFilter = exactDepartment
|
||||
? " AND CodDepCod = :p" : " AND SUBSTR(CodDepCod, 1, 2) = :p";
|
||||
return Rows(
|
||||
"SELECT ComAcpDtm FROM P_ComInf"
|
||||
+ " INNER JOIN P_CodInf ON ComNum = CodComNum"
|
||||
+ " WHERE ComChtNum = RPAD(:c, 10)"
|
||||
+ " AND ComAcpDtm >= :s || '0000' AND ComAcpDtm <= :e || '9999'"
|
||||
+ depFilter + " AND ComAcpStt <> 'OC' ORDER BY ComAcpDtm",
|
||||
("c", chtNum), ("s", first[..8]), ("e", last.Length >= 8 ? last[..8] : string.Empty),
|
||||
("p", depCod))
|
||||
.Select(r => r.TryGetValue("ComAcpDtm", out var v) ? v : string.Empty)
|
||||
.Where(v => v.Length >= 8)
|
||||
.ToArray();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 적용 진료과 이름(OCM_적용진료과, :5659) — 통합 대표행의 AdpDepCod/AdpDtm 은
|
||||
/// 진료행(CodDepCod)과 적용일시의 별칭이다(dtHISOperatingInfo.vb:3699).
|
||||
/// 원문은 적용일시 <b>12자리 전체</b>를 8자리 유효기간과 비교한다(절단 없음) — 그대로.
|
||||
/// </summary>
|
||||
public string AppliedDepartmentName(string depCod, string adpDtm)
|
||||
=> depCod.Trim().Length == 0 || adpDtm.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT DepKorNam v FROM M_DepMst"
|
||||
+ " WHERE TRIM(DepCod) = :d AND :y BETWEEN DepStrDte AND DepEndDte)"
|
||||
+ " WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()), ("y", adpDtm.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// 전과 목록(OCM_전과, :12924 — GetCodInfDT(With_Join, 전체) 대응, dtCommonLib.vb:2777-2832).
|
||||
/// 원문의 이름 조인(오늘 기준 유효기간, DepWrkTyp IN ('D','A') — SYBS·NYHY 의 'B' 추가는
|
||||
/// 미이식)과 정렬(CodStrDtm 오름차순) 그대로. 원문이 DefaultView 에 건 필터·정렬은
|
||||
/// 순회에 반영되지 않지만 SQL 이 이미 같은 조건·순서라 실질 무해했다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> TransferList(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT DepKorNam AS DepNam, Cod.CodDepCod, Cod.CodStrDtm, Cod.CodEndDtm FROM P_CodInf Cod"
|
||||
+ " LEFT OUTER JOIN M_UidMst Usr ON Usr.UidCod = CodDtrCod AND Usr.UidDtrYon = 'Y'"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN Usr.UidStrDte AND Usr.UidEndDte"
|
||||
+ " LEFT OUTER JOIN M_DepMst Dep ON DepCod = CodDepCod AND DepHspCod = Usr.UidHspCod"
|
||||
+ " AND DepWrkTyp IN ('D','A')"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
|
||||
+ " WHERE CodComNum = :m AND CodMtiSeq = '0' ORDER BY CodStrDtm",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 간호정보조사지의 과거력 행(OCM_간호정보조사지_과거력 :13030 + GetNurseEmrDT :13523 +
|
||||
/// GetNurseShtCod :13770) — 서식 우선순위(S113 성인 → S202 소아 → S163 산모, SRCH 갈래)로
|
||||
/// 고른 기록의 병력 항목 6칸을 PIVOT 한 행. 레거시는 서식 확인 3왕복 + 본조회였다 —
|
||||
/// 우선순위를 CASE 정렬로 접어 한 왕복이다. 기록이 없으면 빈 사전.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> NurseHistoryRow(decimal comNum)
|
||||
=> Row(
|
||||
"WITH Sht AS (SELECT v FROM (SELECT EmrShtCod v FROM E_EmrInf"
|
||||
+ " WHERE EmrComNum = :m AND EmrShtCod IN ('S113','S202','S163')"
|
||||
+ " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D')"
|
||||
+ " ORDER BY CASE EmrShtCod WHEN 'S113' THEN 1 WHEN 'S202' THEN 2 ELSE 3 END)"
|
||||
+ " WHERE ROWNUM = 1),"
|
||||
+ " Data AS (SELECT SctObjNam, EmdRecord, ShtCod FROM E_EmrInf"
|
||||
+ " INNER JOIN E_ShtMst ON ShtCod = EmrShtCod"
|
||||
+ " INNER JOIN E_EmdInf ON EmdEmrKey = EmrKey"
|
||||
+ " INNER JOIN E_SctMst ON SctKey = EmdItmKey AND SctObjNam IN"
|
||||
+ " ('chkMedHis_1','chkMedHis_2','chkMedHis_3','chkMedHis_4','chkMedHis_5','txtMedHis')"
|
||||
+ " WHERE EmrComNum = :m AND EmrShtCod = (SELECT v FROM Sht)"
|
||||
+ " AND (EmrSttFlg IS NULL OR EmrSttFlg <> 'D'))"
|
||||
+ " SELECT * FROM (SELECT EmdRecord, SctObjNam, ShtCod FROM Data)"
|
||||
+ " PIVOT (MIN(EmdRecord) FOR SctObjNam IN ('chkMedHis_1' AS 당뇨, 'chkMedHis_2' AS 고혈압,"
|
||||
+ " 'chkMedHis_3' AS 간염, 'chkMedHis_4' AS 결핵, 'chkMedHis_5' AS 병력기타,"
|
||||
+ " 'txtMedHis' AS 병력기타내용))",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 영상 촬영 이력(OCM_영상촬영이력, :13968) — 영상의학(XRAY) 접수 완료 오더의
|
||||
/// 일자·이름(30자 절단, 원문 SUBSTR). 원문 정렬은 8자 절단 별칭이라 같은 날 안 순서가
|
||||
/// 임의였다 — 원본 일시로 정렬해 고정한다(상위 순서는 동일).
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> RadiologyHistory(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT SUBSTR(OdrDtm, 1, 8) AS OdrDay, SUBSTR(OdrNam, 1, 30) AS OdrNam FROM O_OdrInf"
|
||||
+ " WHERE OdrComNum = :m AND OdrAcpDep = 'XRAY' AND OdrStkStt = 'OE'"
|
||||
+ " ORDER BY OdrDtm DESC",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 나이(개월 표기 포함) — 통합 대표행의 AgeMonth 파생 컬럼과 같은 식
|
||||
/// (dtCommonLib.vb:3725 — UDF_GETAGE_FROMRESNUM(복호화 주민번호, 적용일시)).
|
||||
/// PAT_나이의 Case Else 갈래가 이 컬럼을 그대로 돌려준다(CGCH 분기 미이식).
|
||||
/// 암호화 N 병원 갈래(PatResNum 직접)는 미이식 — 이 DB 는 암호화 Y 다.
|
||||
/// </summary>
|
||||
public string AgeMonthText(string chtNum, string adpDtm)
|
||||
=> Scalar(
|
||||
"SELECT UDF_GETAGE_FROMRESNUM(UDF_GetDecResNum(PatResEnc), :a) FROM P_PatInf"
|
||||
+ " WHERE PatChtNum = RPAD(:c, 10)",
|
||||
("a", adpDtm), ("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 정수 나이 — 통합 대표행의 AGE 파생 컬럼과 같은 식
|
||||
/// (dtCommonLib.vb:3712 — UDF_GETAGE(PatBthDay, 적용일 8자)).
|
||||
/// PAT_나이_세의 산정코드 24* 갈래가 이 컬럼을 쓴다.
|
||||
/// </summary>
|
||||
public string AgeYearsUdf(string chtNum, string day8)
|
||||
=> Scalar(
|
||||
"SELECT UDF_GETAGE(PatBthDay, :a) FROM P_PatInf WHERE PatChtNum = RPAD(:c, 10)",
|
||||
("a", day8), ("c", chtNum));
|
||||
|
||||
/// <summary>
|
||||
/// 수술 집도의의 진료과/그룹진료과 이름(OCM_수술집도의_진료과 :3157 · _그룹진료과 :3194) —
|
||||
/// 수술 신청의 집도의(S_OprTyp OprTyp='DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 푼다.
|
||||
/// 원문 특이점 그대로: OprStt 필터가 없고(취소 수술 포함), 유효기간이 수술일이 아니라
|
||||
/// <b>오늘</b> 기준이다. 최신 수술(OprKey DESC) 첫 행으로 접는다(원문 Rows(0)).
|
||||
/// </summary>
|
||||
public string SurgeonDepartment(decimal comNum, bool groupName)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT "
|
||||
+ (groupName ? "DtlCodNam" : "DepKorNam") + " v FROM S_OprInf"
|
||||
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'DTRUIDCOD'"
|
||||
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
|
||||
+ (groupName
|
||||
? " INNER JOIN M_DtlMst ON DepGrpCod = DtlCod AND DtlTblCod = 'GDPTBL'"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
|
||||
: string.Empty)
|
||||
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 수술명 목록(OCM_수술명칭, :3520 Case Else) — 확정 수술의 수술코드명(OprTyp='OPRCOD')
|
||||
/// 을 수술일 순으로. GNBEDRO·HIMCHAN_BP 의 OPNAME 포함 분기는 미이식.
|
||||
/// 결합(첫 행 뒤 공백, 이후 CRLF)은 해석기가 원문대로 한다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SurgeryNames(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT OprCodNam FROM S_OprInf"
|
||||
+ " INNER JOIN S_OprTyp ON OprKeyNum = OprKey"
|
||||
+ " WHERE OprComNum = :m AND OprStt = 'E' AND OprTyp = 'OPRCOD'"
|
||||
+ " ORDER BY OprDte",
|
||||
("m", comNum))
|
||||
.Select(r => r.TryGetValue("OprCodNam", out var v) ? v : string.Empty)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// 퇴원약 목록(퇴원약DT :10482 — 파생 6종·OCM_퇴원약·_New 의 공통 조회).
|
||||
/// 퇴원의약(OdrDscMed) 오더를 일시·코드·이름 순으로. 1회 투여량(QtyTmsQty)은
|
||||
/// SQL 계산 그대로(수량형 '*' 이면 원량, 아니면 총량/횟수 반올림 3자리).
|
||||
/// OdrRefCmt 는 OCM_퇴원약(SATCH 분기)용 컬럼 — 파생 6종은 쓰지 않는다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> DischargeMedications(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT OdrCod, OdrNam, OdrTms, OdrDay, UsgKorNam, OdrRefCmt,"
|
||||
+ " CASE WHEN OdrQtyTyp = '*' THEN OdrDtrQty ELSE ROUND(OdrDtrQty / OdrTms, 3) END AS QtyTmsQty"
|
||||
+ " FROM O_OdrInf"
|
||||
+ " LEFT JOIN M_UsgMst ON OdrUsgCod = UsgCod"
|
||||
+ " AND OdrDtm BETWEEN UsgStrDte || '0000' AND UsgEndDte || '2359'"
|
||||
+ " WHERE OdrComNum = :m AND OdrStt = 'OE'"
|
||||
+ " AND (OdrDscMed IS NOT NULL AND OdrDscMed <> ' ')"
|
||||
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
|
||||
+ " AND OdrGrpSub = 0"
|
||||
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
|
||||
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
|
||||
+ " ORDER BY OdrDtm, OdrCod, OdrNam",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 비급여 오더 행들(OCM_비급여처방, :10964 — Case Else + SRCH 조건 갈래).
|
||||
/// SRCH 는 고지 대상(SgaNtiNon='Y')이고 수가 그룹(SgaHgnCod)이 있는 것만 본다.
|
||||
/// TYRCH(UNION)·BSGH(치료실)·GNRH·BSQ·SUSS 분기는 미이식.
|
||||
/// </summary>
|
||||
public IReadOnlyList<IReadOnlyDictionary<string, string>> NonInsuredOrders(decimal comNum)
|
||||
=> Rows(
|
||||
"SELECT SgaKorNam, SgaHthNon, SgaGudNon, SgaCarNon, SgaWrkNon, SgaGenFor, SgaGenNon"
|
||||
+ " FROM O_OdrInf"
|
||||
+ " INNER JOIN O_OdsInf ON OdrKey = OdsOdrKey AND OdsStt = 'OE'"
|
||||
+ " LEFT JOIN M_SgaMst ON OdrCod = SgaCod"
|
||||
+ " AND OdrDtm BETWEEN SgaStrDte AND SgaEndDte || '2359'"
|
||||
+ " WHERE OdrComNum = :m AND OdrStt = 'OE' AND OdrStkStt = 'OE'"
|
||||
+ " AND OdrInsYon = '3'"
|
||||
+ " AND (OdrHidden IS NULL OR OdrHidden <> 1)"
|
||||
+ " AND OdrGrpSub = 0"
|
||||
+ " AND (OdrAutCod IS NULL OR OdrAutCod = '-')"
|
||||
+ " AND (OdrCod IS NOT NULL AND OdrCod <> '-')"
|
||||
+ " AND (OdrSimEdt IS NULL OR OdrSimEdt NOT IN ('I','E'))"
|
||||
+ " AND SgaNtiNon = 'Y'"
|
||||
+ " AND (SgaHgnCod IS NOT NULL AND SgaHgnCod <> ' ')"
|
||||
+ " GROUP BY SgaKorNam, SgaHthNon, SgaGudNon, SgaCarNon, SgaWrkNon, SgaGenFor, SgaGenNon"
|
||||
+ " ORDER BY SgaKorNam",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 특수 계산코드 2종(M_DtlMst SGACFG 의 Use_SgaCA1/Use_SgaCA2, DtlCodVal 의 첫 '/' 조각 —
|
||||
/// clsCommonLib.vb:3946-3999). 환자 계산코드가 여기 맞으면 레거시 비급여처방은
|
||||
/// SELECT 에 없는 컬럼(SgaCA1Non)을 읽어 <b>항상 빈 값</b>이었다 — 해석기가 사유로 만든다.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> SpecialNonInsuredCalCodes()
|
||||
=> Rows("SELECT DtlCod, DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'SGACFG'")
|
||||
.Where(r => r.TryGetValue("DtlCod", out var c)
|
||||
&& c.Trim().ToUpperInvariant() is "USE_SGACA1" or "USE_SGACA2")
|
||||
.Select(r => (r.TryGetValue("DtlCodVal", out var v) ? v : string.Empty)
|
||||
.Split('/')[0].Trim())
|
||||
.Where(v => v.Length > 0)
|
||||
.ToArray();
|
||||
|
||||
/// <summary>
|
||||
/// 진료과의 그룹과목 행(OCM_진단서전문그룹과목, :11245 쿼리1) — 오늘 기준.
|
||||
/// 원문은 0행이면 Rows(0) 크래시였다 — 여기는 빈 사전(해석기가 사유로).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> GroupSubjectRow(string depCod)
|
||||
=> depCod.Trim().Length == 0
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: Row(
|
||||
"SELECT * FROM (SELECT DepBilCod, DepMedDtl, DtlCodNam FROM M_DepMst"
|
||||
+ " INNER JOIN M_DtlMst ON DepGrpCod = DtlCod AND DtlTblCod = 'GDPTBL'"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtlStrDte AND DtlExpDte"
|
||||
+ " WHERE TRIM(DepCod) = :d"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte)"
|
||||
+ " WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// 코드표 이름 — 레거시 GetDtlCodNam(tbl, cod)(기간 조건 없음, clsCommonLib.vb:7020) 대응.
|
||||
/// 다중이면 원문 첫 행 — 시작일 최신으로 고정한다.
|
||||
/// </summary>
|
||||
public string CodeName(string tableCod, string cod)
|
||||
=> cod.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT DtlCodNam v FROM M_DtlMst"
|
||||
+ " WHERE DtlTblCod = :t AND DtlCod = :c ORDER BY DtlStrDte DESC) WHERE ROWNUM = 1",
|
||||
("t", tableCod), ("c", cod.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// 임신 정보 최신 행(OCM_임신주기, :11845) — 분만예정일(PrgEddDte)·최종월경일(PrgLmpDte)
|
||||
/// 과 서버 오늘. 갱신일시 최신 행(원문 정렬 그대로).
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> PregnancyRow(decimal comNum)
|
||||
=> Row(
|
||||
"SELECT * FROM (SELECT PrgEddDte, PrgLmpDte, TO_CHAR(SYSDATE, 'YYYYMMDD') AS Today"
|
||||
+ " FROM O_PrgInf WHERE PrgComNum = :m ORDER BY PrgUpdDtf DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 낮병동 신청 구간(OCM_재원일수_낮병동, :8083) — 시작·종료일 두 컬럼.
|
||||
/// 입원일자_낮병동과 같은 조건이고 결정 규칙(시작일 최신)도 같다.
|
||||
/// </summary>
|
||||
public IReadOnlyDictionary<string, string> DayCareRange(decimal comNum)
|
||||
=> Row(
|
||||
"SELECT * FROM (SELECT DcpStrDte, DcpEndDte FROM P_ComInf"
|
||||
+ " INNER JOIN P_DcpInf ON ComChtNum = DcpChtNum"
|
||||
+ " AND ComAcpDtm BETWEEN DcpStrDte AND DcpEndDte || '2359'"
|
||||
+ " AND DcpAcpStt = 'A'"
|
||||
+ " WHERE ComNum = :m AND ComAcpStt <> 'OC' AND ComDayCar = 'Y'"
|
||||
+ " ORDER BY DcpStrDte DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 로그인 부서의 수간호사 이름(ETC_로그인사용자_병동_수간호사, :15507) —
|
||||
/// 직급 코드표(PSTCOD)에서 '수간호사'/'간호수선생' 코드를 찾아 그 직급의 재직자.
|
||||
/// 레거시는 두 쿼리 모두 정렬 없는 Rows(0) — 코드·사용자 코드 순으로 고정하고
|
||||
/// 한 왕복으로 접었다(레거시도 첫 직급 코드 하나만 쓴다).
|
||||
/// </summary>
|
||||
public string WardHeadNurse(string depCod)
|
||||
=> depCod.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT UidNam v FROM M_UidMst"
|
||||
+ " WHERE TRIM(UidDepCod) = :d"
|
||||
+ " AND UidPstCod IN (SELECT c FROM (SELECT DtsCod c FROM M_DtsMst"
|
||||
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
|
||||
+ " AND DtsDtlCod = 'PSTCOD' AND DtsCodNam IN ('수간호사','간호수선생')"
|
||||
+ " ORDER BY DtsCod) WHERE ROWNUM = 1)"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()));
|
||||
|
||||
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열. 잔여 공백은 걷는다(문맥 행과 같은 규칙)</summary>
|
||||
private string Scalar(string sql, params (string Name, object Value)[] binds)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in binds)
|
||||
{
|
||||
command.Parameters.Add(new OracleParameter(name, value));
|
||||
}
|
||||
var result = command.ExecuteScalar();
|
||||
return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
|
||||
}
|
||||
|
||||
/// <summary>행 하나를 컬럼명(대문자) → 값 사전으로 — 없으면 빈 사전</summary>
|
||||
private IReadOnlyDictionary<string, string> Row(string sql, params (string Name, object Value)[] binds)
|
||||
{
|
||||
var rows = Rows(sql, binds);
|
||||
return rows.Count > 0 ? rows[0] : new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
/// <summary>여러 행 — 각 행은 컬럼명 → Trim 값 사전</summary>
|
||||
private IReadOnlyList<IReadOnlyDictionary<string, string>> Rows(
|
||||
string sql, params (string Name, object Value)[] binds)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in binds)
|
||||
{
|
||||
command.Parameters.Add(new OracleParameter(name, value));
|
||||
}
|
||||
var rows = new List<IReadOnlyDictionary<string, string>>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row[reader.GetName(i)] = reader.IsDBNull(i) ? string.Empty : reader.GetValue(i).ToString()!.Trim();
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,288 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>환자 검색 방식 — 레거시 fmPatVisitList 의 검색 유형 네 가지</summary>
|
||||
public enum PatientSearchKind
|
||||
{
|
||||
/// <summary>차트번호 — 레거시는 등호 완전일치다(병원별 zero-pad 후 전달)</summary>
|
||||
ChartNumber,
|
||||
|
||||
/// <summary>성명 — 접두 일치</summary>
|
||||
Name,
|
||||
|
||||
/// <summary>주민번호 — 하이픈을 떼고 접두 일치</summary>
|
||||
ResidentNumber,
|
||||
|
||||
/// <summary>휴대전화 — 부분 일치</summary>
|
||||
MobilePhone,
|
||||
}
|
||||
|
||||
/// <summary>환자 검색 결과 1건</summary>
|
||||
public sealed record PatientSummary(
|
||||
string ChtNum, string Name, string ResNumMasked, string MobilePhone,
|
||||
string LastVisit, bool Inpatient, bool Shielded, bool Active);
|
||||
|
||||
/// <summary>내원 1건</summary>
|
||||
public sealed record VisitSummary(
|
||||
decimal ComNum, string Kind, string AcceptedAt, string LeftAt,
|
||||
string Department, string Doctor, string Insurance, string State)
|
||||
{
|
||||
/// <summary>접수일시 읽기 좋게 — 목록에서 12자리 숫자를 사람이 읽으려면 자리를 세어야 한다</summary>
|
||||
public string AcceptedAtText => Readable(AcceptedAt);
|
||||
|
||||
/// <summary>퇴원일시 읽기 좋게. 재원 중이면 빈칸이다</summary>
|
||||
public string LeftAtText => Readable(LeftAt);
|
||||
|
||||
/// <summary>
|
||||
/// YYYYMMDDHHMM → "2026-08-18 09:30". 길이가 다르면 <b>손대지 않고 그대로</b> 보여 준다 —
|
||||
/// 잘라서 그럴듯하게 만들면 데이터가 이상한 것을 화면이 감춘다.
|
||||
/// </summary>
|
||||
private static string Readable(string stamp)
|
||||
{
|
||||
var value = (stamp ?? string.Empty).Trim();
|
||||
return value.Length == 12
|
||||
? $"{value[..4]}-{value[4..6]}-{value[6..8]} {value[8..10]}:{value[10..12]}"
|
||||
: value;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 환자·내원 조회 — 미리보기에 환자 문맥을 붙이기 위한 최소 조회.
|
||||
///
|
||||
/// <b>레거시에서 옮기지 않는 것 셋.</b> 원본(<c>dtPatVisitList.vb:42, 133</c>)은
|
||||
/// ① <b>바인드 변수를 하나도 쓰지 않는다</b> — 검색어를 SQL 문자열에 그대로 잇고 따옴표 이스케이프도 없다.
|
||||
/// 성명 칸에 작은따옴표를 넣으면 그대로 SQL 이 된다.
|
||||
/// ② <b>행 제한이 없다</b>(ROWNUM/FETCH 어디에도 없음) — 성명 한 글자로 전 환자가 클라이언트로 온다.
|
||||
/// ③ <b>TRIM·UPPER 정규화가 없다</b> — CHAR 고정폭 잔여 공백에 걸린다.
|
||||
/// 셋 다 이 저장소에서는 <see cref="OracleLegacyFormStore"/> 규범대로 고친다.
|
||||
/// 와일드카드는 SQL 리터럴이 아니라 <b>파라미터 값</b>에 붙인다.
|
||||
///
|
||||
/// <b>컬럼을 다 가져오지 않는다.</b> 레거시 SELECT 는 도로명·지번 주소를 CASE 로 조립하고
|
||||
/// <c>M_ZipMst</c> 를 조인하는 등 화면 12열을 채우려 크다. 미리보기 환자 선택에 필요한 것은
|
||||
/// 사람을 특정할 수 있는 최소한이므로 그만 읽는다 — 개인정보는 덜 읽는 쪽이 낫다.
|
||||
///
|
||||
/// <b>주민번호는 마스킹된 것만 읽는다.</b> 레거시는 암호화 병원에서 복호화한 평문을 그리드에 그대로 띄우고
|
||||
/// 마스킹을 실제로 쓰는 병원은 셋뿐이다(<c>fmPatVisitList.vb:864-869</c>).
|
||||
/// 여기서는 사람을 가려낼 수 있으면 충분하므로 <c>UDF_GetMaskedResNum</c> 결과만 가져온다.
|
||||
/// </summary>
|
||||
public sealed class PatientVisitStore
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>기본 행 상한 — 레거시에는 없다. 사람이 눈으로 고르는 목록이라 이보다 많으면 검색어를 좁혀야 한다</summary>
|
||||
public const int DefaultLimit = 200;
|
||||
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PatientVisitStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 환자 검색. 검색어가 비면 아무것도 돌려주지 않는다 —
|
||||
/// 조건 없는 전 환자 조회는 레거시에서도 사고였고 여기서 되살릴 이유가 없다.
|
||||
/// </summary>
|
||||
public List<PatientSummary> Search(PatientSearchKind kind, string term, int max = DefaultLimit)
|
||||
{
|
||||
var value = (term ?? string.Empty).Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return new List<PatientSummary>();
|
||||
}
|
||||
// 주민번호는 하이픈을 떼고 본다(레거시 :96-100 과 같다).
|
||||
// 최소 2글자 — 레거시도 이것만은 제한한다(fmPatVisitList.vb:761-763).
|
||||
if (kind == PatientSearchKind.ResidentNumber)
|
||||
{
|
||||
value = value.Replace("-", string.Empty);
|
||||
if (value.Length < 2)
|
||||
{
|
||||
return new List<PatientSummary>();
|
||||
}
|
||||
}
|
||||
|
||||
var (where, bound) = kind switch
|
||||
{
|
||||
// 레거시와 같이 완전일치. 호출부가 병원 규약대로 자릿수를 맞춰 넘긴다.
|
||||
PatientSearchKind.ChartNumber => ("TRIM(PatChtNum) = :v", value),
|
||||
PatientSearchKind.Name => ("PatNam LIKE :v", value + "%"),
|
||||
PatientSearchKind.ResidentNumber => ("PatResNum LIKE :v", value + "%"),
|
||||
// 레거시는 양쪽 와일드카드라 인덱스를 못 탄다. 뒤에서 찾는 것이 실사용이라 그대로 두되
|
||||
// 행 상한이 있으므로 전건 스캔이 화면까지 오지는 않는다.
|
||||
_ => ("PatMblPhn LIKE :v", "%" + value + "%"),
|
||||
};
|
||||
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
// 처음에는 최종내원일시와 재원여부를 <b>파생표 두 개</b>로 붙였다.
|
||||
// 둘 다 P_ComInf 를 통째로 GROUP BY 한 뒤에야 조인되므로,
|
||||
// 조건에 맞는 환자가 몇 명이든 <b>내원 테이블 전체</b>를 집계했다 — 실측 5,025ms.
|
||||
//
|
||||
// 최종내원일시는 <b>화면에 쓰지도 않았다</b>(목록 열은 차트번호·성명·주민번호·휴대전화·비고).
|
||||
// 보이지도 않는 값을 위해 5초를 쓰고 있었다. 빼면 그 집계가 사라진다.
|
||||
//
|
||||
// 재원여부는 비고 배지에 쓰므로 남기되, 여기서 계산하지 않는다 —
|
||||
// 행 상한을 먼저 걸고 <b>돌아온 환자에 대해서만</b> 따로 읽는다(아래 FillInpatient).
|
||||
command.CommandText =
|
||||
"SELECT * FROM ("
|
||||
+ " SELECT TRIM(PatChtNum) ChtNum, NVL(PatNam,' ') Nam,"
|
||||
+ " NVL(UDF_GetMaskedResNum(PatResNum),' ') ResMasked,"
|
||||
+ " NVL(PatMblPhn,' ') Mbl,"
|
||||
+ " NVL(PatInfShd,' ') Shd, NVL(PatUseYon,' ') UseYon"
|
||||
+ " FROM P_PatInf"
|
||||
+ $" WHERE {where}"
|
||||
+ " ORDER BY PatNam, PatChtNum"
|
||||
+ $") WHERE ROWNUM <= {max}";
|
||||
command.Parameters.Add(new OracleParameter("v", bound));
|
||||
|
||||
var result = new List<PatientSummary>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new PatientSummary(
|
||||
reader.GetString(0).Trim(),
|
||||
reader.GetString(1).Trim(),
|
||||
reader.GetString(2).Trim(),
|
||||
reader.GetString(3).Trim(),
|
||||
// 최종내원일시는 더 이상 읽지 않는다 — 화면에 없는 값이었고 집계가 5초였다
|
||||
string.Empty,
|
||||
false,
|
||||
reader.GetString(4).Trim() == "Y",
|
||||
reader.GetString(5).Trim() != "N"));
|
||||
}
|
||||
reader.Close();
|
||||
return FillInpatient(connection, result);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 돌아온 환자들의 <b>재원 여부만</b> 한 번 더 읽는다 — 비고 배지에 쓴다.
|
||||
///
|
||||
/// 검색 SQL 안에서 계산하면 조건에 맞는 환자가 몇 명이든 P_ComInf 전체를 집계한다.
|
||||
/// 여기서는 행 상한이 이미 걸린 뒤라 <b>차트번호 목록으로 한정</b>해서 읽는다.
|
||||
/// 왕복이 하나 늘지만 집계 범위가 전체 → 최대 200명으로 줄어든다.
|
||||
///
|
||||
/// 차트번호는 <b>바인드로</b> 넘긴다 — 값을 SQL 에 이어 붙이면 레거시의 주입 구멍을 되살린다.
|
||||
/// </summary>
|
||||
private static List<PatientSummary> FillInpatient(
|
||||
OracleConnection connection, List<PatientSummary> patients)
|
||||
{
|
||||
if (patients.Count == 0)
|
||||
{
|
||||
return patients;
|
||||
}
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
var names = new List<string>();
|
||||
for (var i = 0; i < patients.Count; i++)
|
||||
{
|
||||
var name = "c" + i;
|
||||
names.Add(":" + name);
|
||||
command.Parameters.Add(new OracleParameter(name, patients[i].ChtNum));
|
||||
}
|
||||
command.CommandText =
|
||||
"SELECT DISTINCT TRIM(ComChtNum) ChtNum FROM P_ComInf"
|
||||
+ $" WHERE TRIM(ComChtNum) IN ({string.Join(",", names)})"
|
||||
+ " AND ComPatTyp = 'I' AND ComAcpStt NOT IN ('OC','IC')"
|
||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') || '0000' <= ComLevDtm"
|
||||
+ " AND ComAcpDtm <= TO_CHAR(SYSDATE,'YYYYMMDD') || '2359'";
|
||||
|
||||
var inpatients = new HashSet<string>(StringComparer.Ordinal);
|
||||
using (var reader = command.ExecuteReader())
|
||||
{
|
||||
while (reader.Read())
|
||||
{
|
||||
inpatients.Add(reader.GetString(0).Trim());
|
||||
}
|
||||
}
|
||||
for (var i = 0; i < patients.Count; i++)
|
||||
{
|
||||
if (inpatients.Contains(patients[i].ChtNum))
|
||||
{
|
||||
patients[i] = patients[i] with { Inpatient = true };
|
||||
}
|
||||
}
|
||||
return patients;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 환자의 내원 목록 — 최근 것이 위.
|
||||
///
|
||||
/// 레거시 <c>GetComInfo</c> 는 조인 10개로 금액 열까지 채우려 하지만
|
||||
/// <b>그 여섯 열은 값을 받지 못해 화면에서 늘 비어 있다</b>(fmPatVisitList.vb enum 선언에만 존재).
|
||||
/// 옮기지 않는다. 여기서는 어느 내원인지 사람이 고를 수 있을 만큼만 읽는다.
|
||||
///
|
||||
/// 기간 PK 를 가진 마스터(M_DepMst·M_UidMst·M_InsMst)는 그냥 조인하면 같은 내원이 여러 번 뜬다 —
|
||||
/// <c>ROW_NUMBER() … Rn = 1</c> 로 접는다(OracleLegacyFormStore.cs:94-100 과 같은 이유).
|
||||
/// </summary>
|
||||
public List<VisitSummary> ListVisits(string chtNum, int max = DefaultLimit)
|
||||
{
|
||||
var key = (chtNum ?? string.Empty).Trim();
|
||||
if (key.Length == 0)
|
||||
{
|
||||
return new List<VisitSummary>();
|
||||
}
|
||||
using var connection = Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText =
|
||||
"SELECT * FROM ("
|
||||
+ " SELECT A.ComNum,"
|
||||
+ " CASE WHEN A.ComPatTyp = 'I' THEN '입원' ELSE '외래' END Kind,"
|
||||
+ " NVL(A.ComAcpDtm,' ') AcceptedAt, NVL(TRIM(A.ComLevDtm),' ') LeftAt,"
|
||||
+ " NVL((SELECT MAX(DepKorNam) KEEP (DENSE_RANK FIRST ORDER BY DepStrDte DESC)"
|
||||
+ " FROM M_DepMst WHERE TRIM(DepCod) = TRIM(B.CodDepCod)),' ') Dep,"
|
||||
+ " NVL((SELECT MAX(UidNam) KEEP (DENSE_RANK FIRST ORDER BY UidStrDte DESC)"
|
||||
+ " FROM M_UidMst WHERE TRIM(UidCod) = TRIM(B.CodDtrCod)),' ') Dtr,"
|
||||
+ " NVL((SELECT MAX(InsNam) KEEP (DENSE_RANK FIRST ORDER BY InsStrDte DESC)"
|
||||
+ " FROM M_InsMst WHERE TRIM(InsCod) = TRIM(C.CoiInsCod)),' ') Ins,"
|
||||
+ " NVL(Stt.DtlCodNam,' ') Stt"
|
||||
+ " FROM P_ComInf A"
|
||||
+ " LEFT JOIN P_CodInf B ON A.ComNum = B.CodComNum AND B.CodMtiSeq = 0"
|
||||
+ " LEFT JOIN P_CoiInf C ON A.ComNum = C.CoiComNum AND C.CoiMtiSeq = 0"
|
||||
// 처음에는 마스터 셋을 ROW_NUMBER 파생표로 접었다. 기간 PK 중복은 해결되지만
|
||||
// <b>마스터 전체를 정렬</b>한 뒤에야 조인되므로 내원이 한 건인 환자도 5초가 걸렸다(실측).
|
||||
// 상관 스칼라로 바꾼다 — 내원 행마다 코드 하나를 인덱스로 찾는다.
|
||||
// MAX(...) KEEP (DENSE_RANK FIRST ORDER BY 시작일 DESC) 가 "가장 최근 것 하나"를
|
||||
// 정렬 없이 뽑아 주고, 결과는 파생표 방식과 같다(중복도 생기지 않는다).
|
||||
+ " LEFT JOIN M_DtlMst Stt ON Stt.DtlTblCod = 'COMPRGSTT' AND Stt.DtlCod = A.ComPrgStt"
|
||||
// 컬럼에 TRIM 을 씌우면 <b>인덱스를 못 탄다</b> — P_ComInf 전건 스캔이 되어
|
||||
// 내원이 한 건인 환자도 5초가 걸렸다(마스터 조인을 고친 뒤에도 그대로였다).
|
||||
// LIKE 접두로 인덱스 범위 스캔을 살리고, 그것만으로는 '123' 이 '1234' 까지 잡으므로
|
||||
// TRIM 등호를 <b>함께</b> 둬서 넘친 행을 걸러 낸다.
|
||||
+ " WHERE A.ComChtNum LIKE :p AND TRIM(A.ComChtNum) = :c"
|
||||
+ " ORDER BY A.ComAcpDtm DESC"
|
||||
+ $") WHERE ROWNUM <= {max}";
|
||||
command.Parameters.Add(new OracleParameter("p", key + "%"));
|
||||
command.Parameters.Add(new OracleParameter("c", key));
|
||||
LastSql = command.CommandText;
|
||||
|
||||
var result = new List<VisitSummary>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
result.Add(new VisitSummary(
|
||||
reader.GetDecimal(0),
|
||||
reader.GetString(1).Trim(),
|
||||
reader.GetString(2).Trim(),
|
||||
reader.GetString(3).Trim(),
|
||||
reader.GetString(4).Trim(),
|
||||
reader.GetString(5).Trim(),
|
||||
reader.GetString(6).Trim(),
|
||||
reader.GetString(7).Trim()));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>마지막으로 실행한 SQL — 진단이 예외를 진단할 때만 쓴다(문법 오류는 문장을 봐야 안다)</summary>
|
||||
public static string LastSql { get; private set; } = string.Empty;
|
||||
|
||||
private OracleConnection Open()
|
||||
{
|
||||
var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
return connection;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 이 병원의 개인정보 암호화 설정 — 주민번호를 어느 컬럼에서 읽어야 하는지 결정한다.
|
||||
/// </summary>
|
||||
/// <param name="Enabled">HSPCFG/PersonalDataEnc 가 Y 인가</param>
|
||||
/// <param name="ResidentNumber">그 아래 ResNum 이 Y 인가 — 이것이 켜져야 PatResEnc 를 읽는다</param>
|
||||
public readonly record struct PersonalDataEncryption(bool Enabled, bool ResidentNumber)
|
||||
{
|
||||
/// <summary>주민번호가 암호화되어 있는가 — 두 단이 모두 Y 여야 한다</summary>
|
||||
public bool ResidentNumberEncrypted => Enabled && ResidentNumber;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주민번호 복호화 — 레거시 <c>dtCommonLib.GetDecResNum</c> (:6960) 을 옮긴 것.
|
||||
///
|
||||
/// <b>앱은 키를 갖지 않는다.</b> 복호화는 오라클 패키지 <c>CRYPTO_AES256</c> 가 하고
|
||||
/// 접근 통제는 DB 권한이다 — 그것이 있어야 할 자리다. 키를 상수로 옮기는 이식은 하지 않는다.
|
||||
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||
///
|
||||
/// <b>앞 7자리는 평문이다.</b> 8자리부터만 암호화되어 있어서 <c>SUBSTR(...,1,7)</c> 을
|
||||
/// 그대로 이어 붙인다. 이걸 빠뜨리면 앞자리가 사라진 번호가 나온다.
|
||||
///
|
||||
/// <b>값을 캐시하지 않는다.</b> 설정(플래그)은 병원당 고정이라 한 번 읽고 들고 있지만,
|
||||
/// 복호화 결과는 들고 있지 않는다 — 가장 민감한 값을 메모리에 오래 두지 않는다.
|
||||
/// </summary>
|
||||
public sealed class PersonalDataStore
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>이 병원만 DAMO 로 바뀌었다(2023-10-14) — dtCommonLib.vb:6979</summary>
|
||||
private const string DamoHospital = "SCHILDREN";
|
||||
|
||||
private readonly string connectionString;
|
||||
private PersonalDataEncryption? cachedFlags;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PersonalDataStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 암호화 설정 — 두 단으로 읽는다(clsCommonLib.vb:5835-5841).
|
||||
///
|
||||
/// ① <c>M_DtlMst</c>(HSPCFG/PersonalDataEnc) 의 <c>DtlCodVal</c> 에서 <c>=</c> 앞 조각
|
||||
/// ② 그것이 Y 일 때만 <c>M_DtsMst</c>(HSPCFG/PersonalDataEnc/ResNum) 의 <c>DtsCodVal</c>
|
||||
///
|
||||
/// ② 를 무조건 읽지 않는 것이 중요하다 — 레거시가 ① 이 Y 일 때만 읽으므로
|
||||
/// ① 이 N 인 병원에서 ② 에 남아 있는 옛 값에 걸리지 않는다.
|
||||
/// </summary>
|
||||
public PersonalDataEncryption Flags()
|
||||
{
|
||||
if (cachedFlags is { } cached)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
var result = new PersonalDataEncryption(false, false);
|
||||
try
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
var head = Scalar(connection,
|
||||
"SELECT DtlCodVal FROM M_DtlMst WHERE DtlTblCod = 'HSPCFG' AND DtlCod = 'PersonalDataEnc'");
|
||||
// DtlCodVal 은 "Y=..." 형태일 수 있다 — 레거시 Piece(...,"=",1) 과 같이 앞 조각만 본다
|
||||
var enabled = Piece(head).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||
var resident = false;
|
||||
if (enabled)
|
||||
{
|
||||
// 컬럼 이름은 DtsTblCod · DtsDtlCod · DtsCod 다(dtCommonLib.vb:1136).
|
||||
// 처음에 DtsTabCod·DtlCod 로 추측해서 ORA-00904 를 맞았다 — 추측하면 여기서 걸린다.
|
||||
var detail = Scalar(connection,
|
||||
"SELECT DtsCodVal FROM M_DtsMst WHERE DtsTblCod = 'HSPCFG'"
|
||||
+ " AND DtsDtlCod = 'PersonalDataEnc' AND DtsCod = 'ResNum'");
|
||||
resident = Piece(detail).Equals("Y", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
result = new PersonalDataEncryption(enabled, resident);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 설정을 못 읽으면 <b>암호화 아님</b>으로 두지 않는다 — 그러면 암호문을 평문처럼 쓴다.
|
||||
// Enabled=false 는 "PatResNum 을 쓰라"는 뜻이 되므로, 여기서는 호출부가
|
||||
// 값을 만들지 못하도록 두 단 다 false 로 두고 복호화도 시도하지 않게 한다.
|
||||
// (호출부는 Flags 를 못 믿을 때 태그를 비우고 사유를 말해야 한다.)
|
||||
cachedFlags = result;
|
||||
throw;
|
||||
}
|
||||
cachedFlags = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 암호문을 평문으로. 빈 값이면 빈 값(레거시 :6966-6968).
|
||||
///
|
||||
/// 병원 갈래를 함께 옮긴다 — SCHILDREN 은 UDF 하나로 끝내고 나머지는
|
||||
/// 앞 7자리 + <c>CRYPTO_AES256.DEC_AES</c> 조립이다.
|
||||
/// </summary>
|
||||
public string Decrypt(string? encrypted, string hospitalCode)
|
||||
{
|
||||
var value = (encrypted ?? string.Empty).Trim();
|
||||
if (value.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var sql = string.Equals(hospitalCode, DamoHospital, StringComparison.OrdinalIgnoreCase)
|
||||
? "SELECT UDF_GETDECRESNUM_damo(:e) FROM DUAL"
|
||||
: "SELECT SUBSTR(:e,1,7) || CRYPTO_AES256.DEC_AES(SUBSTR(:e,8,256)) FROM DUAL";
|
||||
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
command.Parameters.Add(new OracleParameter("e", value));
|
||||
var decrypted = command.ExecuteScalar();
|
||||
return decrypted is null or DBNull ? string.Empty : decrypted.ToString()!.Trim();
|
||||
}
|
||||
|
||||
private static string Scalar(OracleConnection connection, string sql)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
var value = command.ExecuteScalar();
|
||||
return value is null or DBNull ? string.Empty : value.ToString()!.Trim();
|
||||
}
|
||||
|
||||
/// <summary>레거시 <c>Piece(값, "=", 1)</c> — 첫 등호 앞 조각</summary>
|
||||
private static string Piece(string value)
|
||||
{
|
||||
var at = value.IndexOf('=');
|
||||
return (at < 0 ? value : value[..at]).Trim();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 심사 상병 조회 — 레거시 <c>Bkd_상병쿼리</c>(bzDataInterface.vb:14625-14811)의 이식.
|
||||
/// <see cref="DiagnosisStore"/>(O_OkdInf)와 구조가 같고 표만 <b>B_OkdInf</b>(심사분)다.
|
||||
/// 병원 분기는 KIMEYE 하나뿐이라 그 병원만 미이식 안내가 필요하다.
|
||||
/// SQL 은 원문 기계 추출(52줄) — 블록1 UNION 블록2, 주/부 필터는 각 블록의 RN 접기 앞.
|
||||
/// </summary>
|
||||
public sealed class ReviewDiagnosisStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
|
||||
private const string Template = @"
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
|
||||
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam
|
||||
, ROW_NUMBER() OVER(PARTITION BY KcdCod ORDER BY KcdStrDte DESC) AS OKD_RN
|
||||
FROM B_OkdInf
|
||||
INNER JOIN P_ComInf
|
||||
ON OkdComNum = ComNum
|
||||
INNER JOIN M_KcdMst
|
||||
ON KcdCod = TRIM(OkdKcdCod)
|
||||
AND OkdKcdSeq = KcdSeq
|
||||
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
|
||||
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
|
||||
WHERE OkdComNum = :piComNum
|
||||
AND OkdStt = 'E'
|
||||
{TYPE}
|
||||
) OKD
|
||||
WHERE OKD_RN = 1
|
||||
|
||||
UNION
|
||||
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
|
||||
, KCD.KcdCod, KCD.KcdElcCod, KCD.KcdKorNam, KCD.KcdEngNam
|
||||
, ROW_NUMBER() OVER(PARTITION BY KCD.KcdCod ORDER BY KCD.KcdStrDte DESC) AS OKD_RN
|
||||
FROM (
|
||||
SELECT OkdKcdCod, OkdNam, OkdKcdSeq, OkdDspSeq, OkdCfmSus, OkdEntDtf
|
||||
, KcdCod, KcdElcCod, KcdKorNam, KcdEngNam
|
||||
FROM B_OkdInf
|
||||
INNER JOIN P_ComInf
|
||||
ON OkdComNum = ComNum
|
||||
LEFT JOIN M_KcdMst
|
||||
ON KcdCod = TRIM(OkdKcdCod)
|
||||
AND OkdKcdSeq = KcdSeq
|
||||
AND KcdEndDte >= SUBSTR(ComAcpDtm, 1, 8)
|
||||
AND KcdStrDte <= SUBSTR(ComAcpDtm, 1, 8)
|
||||
WHERE OkdComNum = :piComNum
|
||||
AND OkdStt = 'E'
|
||||
) OKD
|
||||
INNER JOIN M_KcdMst KCD
|
||||
ON KCD.KcdCod = TRIM(OkdKcdCod)
|
||||
AND OkdKcdSeq = KCD.KcdSeq
|
||||
WHERE 1=1
|
||||
AND (OKD.KcdCod IS NULL OR OKD.KcdCod = ' ')
|
||||
{TYPE}
|
||||
) OKD
|
||||
WHERE OKD_RN = 1
|
||||
)
|
||||
ORDER BY OkdDspSeq
|
||||
";
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public ReviewDiagnosisStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이 내원의 심사 상병 행들 — 표시 순서대로. 없으면 빈 목록</summary>
|
||||
public List<Dictionary<string, string>> Diagnoses(decimal comNum, DiagnosisStore.Scope scope)
|
||||
{
|
||||
var filter = scope switch
|
||||
{
|
||||
DiagnosisStore.Scope.Main => " AND OkdDspSeq = '1' ",
|
||||
DiagnosisStore.Scope.Sub => " AND OkdDspSeq <> '1' ",
|
||||
_ => string.Empty,
|
||||
};
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = Template.Replace("{TYPE}", filter);
|
||||
command.Parameters.Add(new OracleParameter("piComNum", comNum));
|
||||
|
||||
var rows = new List<Dictionary<string, string>>();
|
||||
using var reader = command.ExecuteReader();
|
||||
while (reader.Read())
|
||||
{
|
||||
var row = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
{
|
||||
row[reader.GetName(i)] = reader.IsDBNull(i)
|
||||
? string.Empty
|
||||
: reader.GetValue(i).ToString()!.Trim();
|
||||
}
|
||||
rows.Add(row);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -11,11 +11,11 @@ namespace SheetMe.Data.Stores;
|
||||
/// <c>ORDER BY SdgUpdDtm DESC, SdgKey DESC</c> 로 활성 버전을 고르므로,
|
||||
/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다.
|
||||
/// </summary>
|
||||
internal static class ServerClock
|
||||
public static class ServerClock
|
||||
{
|
||||
#region Types
|
||||
/// <summary>서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공</summary>
|
||||
internal readonly record struct Stamp(DateTime Value)
|
||||
public readonly record struct Stamp(DateTime Value)
|
||||
{
|
||||
/// <summary>yyyyMMdd (8자리) — ShtAdpDte 류</summary>
|
||||
public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>
|
||||
/// 싸인·직인·로고 태그가 쓰는 <b>원격 이미지 경로</b> 조회 — 레거시 bzDataInterface.vb 의
|
||||
/// 싸인 태그 17종에서 기계 추출했다. 태그 본문은 전부 같은 골격이다:
|
||||
/// 경로를 DB 에서 한 칸 얻고 → 파일서버에서 내려받아 → 로컬 경로를 돌려준다.
|
||||
///
|
||||
/// <b>여기서는 경로만 얻는다.</b> 내려받는 계층(clsFileTransfer)은 이 병원 설정에서
|
||||
/// ActiveX OCX(M_DtlMst 'EMR_FileTransfer' Type=1)라 재현할 수 없다 — 그 판정과
|
||||
/// 대안(레거시 캐시 사용)은 <see cref="Core.Catalog.SignatureImagePath"/> 에 있다.
|
||||
///
|
||||
/// 대부분의 원문이 ORDER BY 없는 Rows(0) 이라 정렬을 명시해 한 행으로 고정했다
|
||||
/// (담당의 때 정한 규칙). 각 메서드 주석에 근거 줄번호를 남긴다.
|
||||
/// </summary>
|
||||
public sealed class SignatureStore
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SignatureStore(string connectionString)
|
||||
{
|
||||
this.connectionString = connectionString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 수술 집도의 싸인(OCM_수술집도의_싸인, :3385) — 최신 수술(OprKey DESC)의 집도의
|
||||
/// (S_OprTyp 'DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 풀어 UidImgPth.
|
||||
/// 원문의 OprStt 무필터·SYSDATE 유효기간을 그대로 옮긴다.
|
||||
/// </summary>
|
||||
public string SurgeonSignature(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
|
||||
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'DTRUIDCOD'"
|
||||
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
|
||||
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 수술 마취의사 싸인(OCM_수술마취의사싸인_수술접수연동, :3235) — 같은 골격에
|
||||
/// 오더 타입만 'ANEUIDCOD'(부서 조인 없음).
|
||||
/// </summary>
|
||||
public string AnesthesiaSignatureBySurgery(decimal comNum)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM S_OprInf"
|
||||
+ " INNER JOIN S_OprTyp ON OprKey = OprKeyNum AND OprComNum = :m AND OprTyp = 'ANEUIDCOD'"
|
||||
+ " INNER JOIN M_UidMst ON UidCod = OprCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " ORDER BY OprKey DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 마취과 의사 싸인(OCM_수술마취의사싸인, :3311) — 환자와 무관하게 <b>마취과 소속</b>
|
||||
/// 재직자 하나. 개원일 2021-03-01 이후 병원은 청구·그룹 코드로, 그 전은 부서명 LIKE
|
||||
/// '%마취%' 로 찾는다(원문 주석: "코드로 하기엔 병원마다 다를거같아서").
|
||||
/// 원문에 ORDER BY 가 없어 임의 행이었다 — 사용자 코드 순 첫 행으로 고정한다.
|
||||
/// </summary>
|
||||
public string AnesthesiaDepartmentSignature(bool byCode)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
|
||||
+ " INNER JOIN M_DepMst ON DepCod = UidDepCod"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DepStrDte AND DepEndDte"
|
||||
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ (byCode
|
||||
? " AND DepBilCod = '09' AND DepGrpCod = 'ANS'"
|
||||
: " AND DepKorNam LIKE '%마취%'")
|
||||
+ " ORDER BY UidCod) WHERE ROWNUM = 1");
|
||||
|
||||
/// <summary>
|
||||
/// 부서 코드로 찾는 의사 싸인 — 영상의학과(ETC_영상의학과의사싸인 :17353, 'XRAY')와
|
||||
/// 진단검사(ETC_진단검사의사싸인 :17578) 계열의 공통형. 기준일은 작업일(없으면 오늘).
|
||||
/// 원문 ORDER BY 없음 → 사용자 코드 순 고정.
|
||||
/// </summary>
|
||||
public string DepartmentDoctorSignature(string depCod, string day8)
|
||||
=> depCod.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
|
||||
+ " WHERE UidDtrYon = 'Y' AND UidDepCod = :d"
|
||||
+ " AND :y BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()), ("y", day8));
|
||||
|
||||
/// <summary>
|
||||
/// 외출·외박 승인자 싸인(ETC_외출외박_확인간호사싸인 :17683 / _확인원무과싸인 :17765) —
|
||||
/// 신청 행의 승인자 코드(병동 OutAdmWrdAppUid / 원무 OutAdmOffAppUid)를 오늘 기준
|
||||
/// 사용자 마스터로 푼다. 원문 두 조회 모두 ORDER BY 없음 → 신청 시작일 최신으로 고정.
|
||||
/// </summary>
|
||||
public string OutingApproverSignature(decimal comNum, bool officeApprover)
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM O_OutAdmInf"
|
||||
+ " INNER JOIN M_UidMst ON UidCod = "
|
||||
+ (officeApprover ? "OutAdmOffAppUid" : "OutAdmWrdAppUid")
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " WHERE OutAdmComNum = :m"
|
||||
+ " ORDER BY NVL(OutAdmStrDtm, OutAdmExpStrDtm) DESC) WHERE ROWNUM = 1",
|
||||
("m", comNum));
|
||||
|
||||
/// <summary>
|
||||
/// 병동 수간호사 싸인(ETC_로그인사용자_병동_수간호사_싸인, :15562) — 이름 태그와 같은
|
||||
/// 조회의 이미지 컬럼. 원문은 부서 코드를 문자열 연결로 넣는다 — 바인드로 정상화.
|
||||
/// </summary>
|
||||
public string WardHeadNurseSignature(string depCod)
|
||||
=> depCod.Trim().Length == 0 ? string.Empty
|
||||
: Scalar(
|
||||
"SELECT v FROM (SELECT UidImgPth v FROM M_UidMst"
|
||||
+ " WHERE TRIM(UidDepCod) = :d"
|
||||
+ " AND UidPstCod IN (SELECT c FROM (SELECT DtsCod c FROM M_DtsMst"
|
||||
+ " WHERE TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN DtsStrDte AND DtsExpDte"
|
||||
+ " AND DtsDtlCod = 'PSTCOD' AND DtsCodNam IN ('수간호사','간호수선생')"
|
||||
+ " ORDER BY DtsCod) WHERE ROWNUM = 1)"
|
||||
+ " AND TO_CHAR(SYSDATE, 'YYYYMMDD') BETWEEN UidStrDte AND UidEndDte"
|
||||
+ " ORDER BY UidCod) WHERE ROWNUM = 1",
|
||||
("d", depCod.Trim()));
|
||||
|
||||
/// <summary>
|
||||
/// 병원 로고(ETC_요양기관명칭_병원로고, :16538) — M_HspMst.HspLgoPth, 무기한 유효
|
||||
/// (HspEndDte='29991231') 행. 원문은 0행이면 Rows(0) 예외였다 — 여기는 빈 값.
|
||||
/// </summary>
|
||||
public string HospitalLogo()
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT HspLgoPth v FROM M_HspMst"
|
||||
+ " WHERE HspEndDte = '29991231' ORDER BY HspCod) WHERE ROWNUM = 1");
|
||||
|
||||
/// <summary>
|
||||
/// 병원 직인(ETC_요양기관명칭_병원직인, :16580) — <b>병원 마스터가 아니라</b>
|
||||
/// 설정 표(DB_REGISTRY, MnuNam='직인경로')의 SelYon 이다(원문에서 M_HspMst.HspStpPth
|
||||
/// 조회는 주석 처리돼 있다). SCHILDREN·HSYUIL·JAH 는 즉시 빈 값 — 미이식 병원이다.
|
||||
/// </summary>
|
||||
public string HospitalStamp()
|
||||
=> Scalar(
|
||||
"SELECT v FROM (SELECT SelYon v FROM DB_REGISTRY"
|
||||
+ " WHERE MnuNam = '직인경로' ORDER BY UidCod) WHERE ROWNUM = 1");
|
||||
|
||||
/// <summary>스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열</summary>
|
||||
private string Scalar(string sql, params (string Name, object Value)[] binds)
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
command.CommandText = sql;
|
||||
foreach (var (name, value) in binds)
|
||||
{
|
||||
command.Parameters.Add(new OracleParameter(name, value));
|
||||
}
|
||||
var result = command.ExecuteScalar();
|
||||
return result is null or DBNull ? string.Empty : result.ToString()!.Trim();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
using Oracle.ManagedDataAccess.Client;
|
||||
|
||||
namespace SheetMe.Data.Stores;
|
||||
|
||||
/// <summary>태그 값을 만드는 데 필요한 재료 — 서버 시각과 로그인 사용자</summary>
|
||||
public readonly record struct TagPreviewContext(
|
||||
string Minute12, string UidCod, string UidNam, string MobilePhone, string OfficePhone)
|
||||
{
|
||||
public static readonly TagPreviewContext Empty =
|
||||
new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
|
||||
public bool HasClock => Minute12.Length >= 12;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 태그 값 미리보기의 재료를 한 번에 읽는다.
|
||||
///
|
||||
/// <b>왜 따로 두는가.</b> 서식생성기에서 값이 나오는 태그는 서버 시각 계열과 로그인 사용자 계열뿐이다
|
||||
/// (나머지는 환자·내원 문맥이 있어야 하고 여기에는 그 문맥이 없다).
|
||||
/// 그래서 조회는 <b>왕복 2회로 끝난다</b> — 시각 1회, 사용자 1행 1회.
|
||||
/// 태그를 고를 때마다 DB 를 치면 목록을 화살표로 훑는 동안 초당 몇 번씩 왕복하게 되므로
|
||||
/// 호출부가 결과를 세션 동안 들고 있게 만든다.
|
||||
///
|
||||
/// 고정 SQL 만 쓴다. 사용자 입력이 SQL 에 닿는 경로를 만들지 않는다 —
|
||||
/// 이 창은 임의 쿼리를 돌리는 곳이 아니다.
|
||||
/// </summary>
|
||||
public sealed class TagPreviewStore
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>미리보기가 편집을 막으면 안 된다 — 늦으면 값 없이 넘어간다</summary>
|
||||
private const int TimeoutSeconds = 5;
|
||||
|
||||
private readonly string connectionString;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public TagPreviewStore(string connectionString) => this.connectionString = connectionString;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>접속 정보가 없으면 미리보기 줄 자체를 감춘다</summary>
|
||||
public bool CanUseDb => connectionString.Length > 0;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 재료를 한 번에 읽는다. 실패는 삼킨다 —
|
||||
/// 미리보기가 안 되는 것과 태그를 못 고르는 것은 다른 일이다.
|
||||
/// </summary>
|
||||
public TagPreviewContext Read(string uidCod, string uidNam)
|
||||
{
|
||||
if (!CanUseDb)
|
||||
{
|
||||
return TagPreviewContext.Empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
using var connection = new OracleConnection(connectionString);
|
||||
connection.Open();
|
||||
|
||||
var stamp = ServerClock.Read(connection, null);
|
||||
var (mobile, office) = ReadPhones(connection, uidCod);
|
||||
return new TagPreviewContext(stamp.Minute12, uidCod, uidNam, mobile, office);
|
||||
}
|
||||
catch (OracleException)
|
||||
{
|
||||
return TagPreviewContext.Empty;
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
return TagPreviewContext.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 연락처 두 칸 — 세션에 없는 것은 이것뿐이라 이 한 줄만 더 읽는다.
|
||||
///
|
||||
/// <b>유효기간으로 고른다.</b> 레거시(GetUidMst, clsCommonLib.vb:10636)는
|
||||
/// <c>:일자 BETWEEN UidStrDte AND UidEndDte</c> 로 행을 고르고,
|
||||
/// 0행이면 코드만으로 다시 찾는다(:10641 폴백). 전에는 조건 없이 첫 행을 집어서
|
||||
/// 이력이 여러 행인 사용자에서 <b>옛 연락처</b>가 나올 수 있었다.
|
||||
/// 판정 일자는 레거시가 로그인 시점(GetAdpDtm 앞 8자)인데 여기는 서버 오늘이다 —
|
||||
/// 자정을 넘겨 계속 켜 둔 세션에서만 갈리는 차이라 받아들이고 기록해 둔다.
|
||||
/// </summary>
|
||||
private static (string Mobile, string Office) ReadPhones(OracleConnection connection, string uidCod)
|
||||
{
|
||||
if (uidCod.Length == 0)
|
||||
{
|
||||
return (string.Empty, string.Empty);
|
||||
}
|
||||
var valid = ReadPhoneRow(connection,
|
||||
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid"
|
||||
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN UIDSTRDTE AND UIDENDDTE", uidCod);
|
||||
return valid ?? ReadPhoneRow(connection,
|
||||
"SELECT UIDMBLPHN, UIDOFFTEL FROM M_UIDMST WHERE UIDCOD = :uid", uidCod)
|
||||
?? (string.Empty, string.Empty);
|
||||
}
|
||||
|
||||
private static (string Mobile, string Office)? ReadPhoneRow(
|
||||
OracleConnection connection, string sql, string uidCod)
|
||||
{
|
||||
using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
command.CommandTimeout = TimeoutSeconds;
|
||||
command.Parameters.Add(new OracleParameter("uid", uidCod));
|
||||
using var reader = command.ExecuteReader();
|
||||
if (!reader.Read())
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return (reader.IsDBNull(0) ? string.Empty : reader.GetString(0),
|
||||
reader.IsDBNull(1) ? string.Empty : reader.GetString(1));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -9,7 +9,25 @@ namespace SheetMe.Data.Stores;
|
||||
public sealed record HisUser(
|
||||
string UidCod, string UidNam,
|
||||
string DepCod, string DepNam,
|
||||
string HspCod, string HspNam)
|
||||
string HspCod, string HspNam,
|
||||
/// <summary>의사 여부(UidDtrYon=Y) — OCM_User진료과 계열이 로그인 사용자 분기에 쓴다</summary>
|
||||
string DoctorYn = "",
|
||||
/// <summary>부서 공식 명칭(DepOfcNam) — 개원일 조건이 걸리는 병원에서 한글명 대신 쓴다</summary>
|
||||
string DepOfficialName = "",
|
||||
/// <summary>의사면허번호(UidLicNum) — ETC_로그인_의사면허번호</summary>
|
||||
string LicenseNumber = "",
|
||||
/// <summary>전문의번호(UidSpcLic) — ETC_로그인_전문의번호</summary>
|
||||
string SpecialistNumber = "",
|
||||
/// <summary>직종명(M_DtsMst JOBCOD 의 DtsCodNam) — ETC_로그인_직종</summary>
|
||||
string JobName = "",
|
||||
/// <summary>부서 전화번호(M_DepMst.DepTelNum) — ETC_접속유저_부서번호(이름과 달리 전화번호다)</summary>
|
||||
string DepPhone = "",
|
||||
/// <summary>영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래</summary>
|
||||
string EnglishName = "",
|
||||
/// <summary>세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호</summary>
|
||||
string DetailSpecialistNumber = "",
|
||||
/// <summary>싸인 이미지 원격 경로(UidImgPth) — 로그인 사용자 싸인 계열</summary>
|
||||
string SignaturePath = "")
|
||||
{
|
||||
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
|
||||
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||
@@ -58,15 +76,27 @@ public sealed class UserContextStore
|
||||
connection.Open();
|
||||
using var command = connection.CreateCommand();
|
||||
command.BindByName = true;
|
||||
// 레거시와 두 가지가 <b>의도적으로</b> 다르다(워크플로 검증이 짚었고, 받아들이기로 했다):
|
||||
// · 유효기간 판정 일자 — 레거시는 로그인 시점(GetAdpDtm 앞 8자, clsCommonLib.vb:10622),
|
||||
// 여기는 SYSDATE. 자정을 넘겨 계속 켜 둔 세션에서만 갈린다.
|
||||
// · Trim — 레거시는 UidCod/UidNam 을 무가공으로 쓰고(bzDataInterface.vb:16713, 16720)
|
||||
// 여기는 걷는다. CHAR 잔여 공백이 종이에 찍히는 쪽이 더 이상하다고 판단했다.
|
||||
command.CommandText =
|
||||
"SELECT u.UidCod, NVL(u.UidNam,' '), " +
|
||||
" NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " +
|
||||
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' ') " +
|
||||
" NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' '), " +
|
||||
" NVL(u.UidDtrYon,' '), NVL(d.DepOfcNam,' '), " +
|
||||
" NVL(u.UidLicNum,' '), NVL(u.UidSpcLic,' '), NVL(j.DtsCodNam,' '), " +
|
||||
" NVL(d.DepTelNum,' '), NVL(u.UidEngNam,' '), NVL(u.UidDtlSpcLic,' '), " +
|
||||
" NVL(u.UidImgPth,' ') " +
|
||||
"FROM M_UidMst u " +
|
||||
"LEFT JOIN M_DepMst d ON d.DepCod = u.UidDepCod " +
|
||||
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN d.DepStrDte AND d.DepEndDte " +
|
||||
"LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
|
||||
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " +
|
||||
// 직종 조인은 레거시 그대로 DtsDtlCod 만으로 건다(bzDataInterface.vb:17944 — DtsTblCod 조건이 없다)
|
||||
"LEFT JOIN M_DtsMst j ON j.DtsDtlCod = 'JOBCOD' AND j.DtsCod = u.UidJobCod " +
|
||||
" AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN j.DtsStrDte AND j.DtsExpDte " +
|
||||
"WHERE u.UidCod = :u " +
|
||||
"AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
|
||||
command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
|
||||
@@ -79,7 +109,11 @@ public sealed class UserContextStore
|
||||
return new HisUser(
|
||||
reader.GetString(0).Trim(), reader.GetString(1).Trim(),
|
||||
reader.GetString(2).Trim(), reader.GetString(3).Trim(),
|
||||
reader.GetString(4).Trim(), reader.GetString(5).Trim());
|
||||
reader.GetString(4).Trim(), reader.GetString(5).Trim(),
|
||||
reader.GetString(6).Trim(), reader.GetString(7).Trim(),
|
||||
reader.GetString(8).Trim(), reader.GetString(9).Trim(), reader.GetString(10).Trim(),
|
||||
reader.GetString(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim(),
|
||||
reader.GetString(14).Trim());
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -41,6 +41,7 @@ public partial class App : Application
|
||||
if (Services.StartupArguments.IsDiagnostic(e.Args))
|
||||
{
|
||||
diagnosticMode = true;
|
||||
Services.StartupArguments.DiagnosticMode = true;
|
||||
// 기본 ShutdownMode 는 OnLastWindowClose 다. 창을 띄웠다 전부 닫는 진단
|
||||
// (--maxrect, --edit-smoke, --dialog-shots …)은 마지막 창을 닫는 순간 WPF 가 먼저
|
||||
// Shutdown(0) 을 걸어 버리고, 뒤이은 Shutdown(RunDiagnostic(...)) 인자는 무시된다 —
|
||||
@@ -77,6 +78,8 @@ public partial class App : Application
|
||||
if (sender is Window window)
|
||||
{
|
||||
Services.WindowChromeTheme.Apply(window, !Services.ThemeManager.IsLight);
|
||||
// 모달이면 뒤를 어둡게 — 여기서 한 번 걸어야 새 대화상자에서 빠지지 않는다
|
||||
Services.ModalScrim.Attach(window);
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -155,6 +158,16 @@ public partial class App : Application
|
||||
return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
|
||||
}
|
||||
|
||||
if (args.Length >= 3 && args[0] == "--db-patient")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunPatientProbe(args[1], args[2]);
|
||||
}
|
||||
|
||||
if (args.Length >= 3 && args[0] == "--db-modify-smoke")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunModifySmoke(args[1], args[2]);
|
||||
}
|
||||
|
||||
if (args.Length >= 3 && args[0] == "--db-save-smoke")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]);
|
||||
@@ -210,6 +223,12 @@ public partial class App : Application
|
||||
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
|
||||
}
|
||||
|
||||
// 싸인·이미지 태그의 전송 수단을 확정한다(값은 안 찍는다)
|
||||
if (args.Length >= 2 && args[0] == "--db-filecfg")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunFileTransferConfig(args[1]);
|
||||
}
|
||||
|
||||
if (args.Length >= 3 && args[0] == "--db-word-smoke")
|
||||
{
|
||||
return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
|
||||
@@ -275,11 +294,27 @@ public partial class App : Application
|
||||
return Diagnostics.SnapShots.Run(args[1]);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "--modal-check")
|
||||
{
|
||||
return Diagnostics.ModalCheck.Run();
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "--maxrect")
|
||||
{
|
||||
return Diagnostics.MaximizeCheck.Run(args.Length >= 2 ? args[1] : null);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "--scale-budget")
|
||||
{
|
||||
var count = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 1067;
|
||||
return Diagnostics.ScaleBudget.Run(args.Length >= 2 ? args[1] : null, count);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "--cleartype")
|
||||
{
|
||||
return Diagnostics.ClearTypeCheck.Run();
|
||||
}
|
||||
|
||||
// 진단 모드에서는 모달을 띄우지 않는다 — 스크립트 옵션 오타 하나로 무인 실행이 여기서
|
||||
// 영원히 멈춘다. WinExe 라 콘솔이 없어 stderr 는 호출자가 리다이렉트할 때만 보이므로
|
||||
// 로그를 함께 남긴다(--db-render 가 .err.txt 로 남기는 것과 같은 이유).
|
||||
|
||||
@@ -40,26 +40,88 @@ public sealed class CanvasKeyboardBehavior : Behavior<UIElement>
|
||||
|
||||
var ctrl = Keyboard.Modifiers.HasFlag(ModifierKeys.Control);
|
||||
var shift = Keyboard.Modifiers.HasFlag(ModifierKeys.Shift);
|
||||
var alt = Keyboard.Modifiers.HasFlag(ModifierKeys.Alt);
|
||||
var step = shift ? designer.Snap.GridSize * 2 : 1;
|
||||
var handled = true;
|
||||
|
||||
switch (e.Key)
|
||||
// Alt 조합은 e.Key 로 오지 않는다 — WPF 가 Key.System 으로 싸서 넘기고 실제 키는 SystemKey 에 있다.
|
||||
// e.Key 만 보면 Alt 단축키가 하나도 안 걸리는데, 그 실패는 "눌러도 아무 일이 없다"로만 보인다.
|
||||
var key = e.Key == Key.System ? e.SystemKey : e.Key;
|
||||
|
||||
switch (key)
|
||||
{
|
||||
// Ctrl+방향키는 <b>크기</b>다 — 위치에는 미세 조정 수단이 있는데 크기만 없었다.
|
||||
// 3px 늘리려고 300px 떨어진 인스펙터 W 칸까지 마우스로 왕복하던 것을 없앤다.
|
||||
// Shift 를 함께 누르면 위치와 같은 큰 걸음(step)을 쓴다.
|
||||
case Key.Left when ctrl: designer.ResizeSelection(-step, 0); break;
|
||||
case Key.Right when ctrl: designer.ResizeSelection(step, 0); break;
|
||||
case Key.Up when ctrl: designer.ResizeSelection(0, -step); break;
|
||||
case Key.Down when ctrl: designer.ResizeSelection(0, step); break;
|
||||
case Key.Left: designer.NudgeSelection(-step, 0); break;
|
||||
case Key.Right: designer.NudgeSelection(step, 0); break;
|
||||
case Key.Up: designer.NudgeSelection(0, -step); break;
|
||||
case Key.Down: designer.NudgeSelection(0, step); break;
|
||||
case Key.Delete: designer.DeleteSelection(); break;
|
||||
case Key.Escape: designer.Interaction.Cancel(); break;
|
||||
|
||||
// 루프를 키보드로 닫는 세 키. 여기까지는 "컨트롤 하나 골라 값 하나 바꾸고 다음으로"가
|
||||
// 마우스 없이 한 바퀴도 돌지 않았다 — 선택을 옮길 키가 하나도 없었다.
|
||||
// PreviewKeyDown 이라 WPF 포커스 관리자보다 먼저 Tab 을 본다(안 그러면 캔버스 밖으로 나간다).
|
||||
case Key.Tab: designer.SelectSibling(backward: shift); break;
|
||||
case Key.Enter: handled = designer.EnterSelection(); break;
|
||||
case Key.F2: handled = designer.EnterSelection(); break;
|
||||
case Key.A when ctrl: designer.SelectAllOnActivePage(); break;
|
||||
case Key.C when ctrl: designer.CopySelection(); break;
|
||||
case Key.X when ctrl: designer.CutSelection(); break;
|
||||
case Key.V when ctrl: designer.Paste(); break;
|
||||
case Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
|
||||
// Ctrl+Shift+Z 는 <b>다시 실행</b>이다. 이 갈래가 없어서 전에는 되돌리기가 한 번 더 됐다 —
|
||||
// 방향이 반대인 줄 모르는 사용자는 "안 돌아오네" 하며 두세 번 더 눌러 편집분을 통째로 날린다.
|
||||
// 실패가 조용히 성공하므로(되돌리기는 언제나 성공한다) 경고도 없다.
|
||||
// 바로 아래 Key.G 가 이미 같은 짝을 가르고 있는데 Z 만 빠져 있었다.
|
||||
case Key.Z when ctrl && shift: designer.Undo.Redo(); break;
|
||||
case Key.Z when ctrl: designer.Undo.Undo(); break;
|
||||
case Key.Y when ctrl: designer.Undo.Redo(); break;
|
||||
case Key.G when ctrl && shift: designer.UngroupSelection(); break;
|
||||
case Key.G when ctrl: designer.GroupSelection(); break;
|
||||
|
||||
// 선택 확장 — 정렬·간격 명령이 전제하는 '올바른 선택'을 만드는 수단.
|
||||
// 마퀴는 교차 판정이라 행 띠를 스치는 것이 전부 딸려 오고(실측 침입자 중앙값 7개),
|
||||
// 그래서 지금까지는 "드래그 → 딸려온 것 빼기"가 매번 붙었다.
|
||||
case Key.D1 when alt: designer.SelectSame(DesignerViewModel.SameKind.Row); break;
|
||||
case Key.D2 when alt: designer.SelectSame(DesignerViewModel.SameKind.Column); break;
|
||||
case Key.D3 when alt: designer.SelectSame(DesignerViewModel.SameKind.Type); break;
|
||||
case Key.D4 when alt: designer.SelectSame(DesignerViewModel.SameKind.Size); break;
|
||||
|
||||
// 잠금·숨김 — 상태와 레이어 패널의 자물쇠는 있었는데 캔버스에서 켤 방법이 없었다.
|
||||
// 배경 괘선 40개를 잠그려면 레이어 트리에서 40번 눌러야 했다.
|
||||
case Key.L when ctrl: designer.ToggleLockSelection(); break;
|
||||
case Key.H when ctrl && shift: designer.ToggleHiddenSelection(); break;
|
||||
|
||||
// 배치 명령 — 전에는 단축키가 <b>0개</b>였고 전부 로고 아래 4단 메뉴 안에만 있었다.
|
||||
// 20px→32px 간격 조정이 4px 씩 12왕복이고 매번 마우스가 좌상단까지 갔다.
|
||||
// 자리는 Figma 와 같게 둔다(Alt+A/D/W/S/H/V) — 이미 그 손버릇을 가진 사람이 있다.
|
||||
case Key.A when alt: designer.Align("Left"); break;
|
||||
case Key.D when alt: designer.Align("Right"); break;
|
||||
case Key.W when alt && !shift: designer.Align("Top"); break;
|
||||
case Key.S when alt && !shift: designer.Align("Bottom"); break;
|
||||
case Key.H when alt && !shift: designer.Align("CenterH"); break;
|
||||
case Key.V when alt && !shift: designer.Align("CenterV"); break;
|
||||
|
||||
// 균등 간격 — 3개 이상일 때만 뜻이 있다
|
||||
case Key.H when alt && shift: designer.AdjustSpacing("HorizEqual"); break;
|
||||
case Key.V when alt && shift: designer.AdjustSpacing("VertEqual"); break;
|
||||
|
||||
// 같은 크기로
|
||||
case Key.W when alt && shift: designer.SizeToControl("Width"); break;
|
||||
case Key.S when alt && shift: designer.SizeToControl("Height"); break;
|
||||
|
||||
// 간격 넓게/좁게 — 세로가 기본(기록지는 줄이 세로로 쌓인다), Shift 로 가로
|
||||
case Key.OemCloseBrackets when alt && !shift: designer.AdjustSpacing("VertIncrease"); break;
|
||||
case Key.OemOpenBrackets when alt && !shift: designer.AdjustSpacing("VertDecrease"); break;
|
||||
case Key.OemCloseBrackets when alt && shift: designer.AdjustSpacing("HorizIncrease"); break;
|
||||
case Key.OemOpenBrackets when alt && shift: designer.AdjustSpacing("HorizDecrease"); break;
|
||||
|
||||
default: handled = false; break;
|
||||
}
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
||||
AssociatedObject.MouseMove += OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp += OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture += OnLostCapture;
|
||||
AssociatedObject.PreviewMouseRightButtonDown += OnRightButtonDown;
|
||||
}
|
||||
|
||||
protected override void OnDetaching()
|
||||
@@ -29,6 +30,7 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
||||
AssociatedObject.MouseMove -= OnMouseMove;
|
||||
AssociatedObject.MouseLeftButtonUp -= OnMouseUp;
|
||||
AssociatedObject.LostMouseCapture -= OnLostCapture;
|
||||
AssociatedObject.PreviewMouseRightButtonDown -= OnRightButtonDown;
|
||||
base.OnDetaching();
|
||||
}
|
||||
|
||||
@@ -54,6 +56,44 @@ public sealed class CanvasMouseBehavior : Behavior<FrameworkElement>
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 우클릭 — 메뉴를 열기 <b>전에</b> 커서 아래 컨트롤을 선택한다.
|
||||
///
|
||||
/// 전에는 우클릭이 선택을 전혀 건드리지 않았다. 라벨 A 를 골라 둔 채 떨어진 체크박스 B 를
|
||||
/// 우클릭하면 메뉴는 B 위에 뜨는데 [삭제]는 <b>A 를 지웠다</b>. 선택이 비어 있으면
|
||||
/// 메뉴 항목이 전부 살아 있는 채로 아무 일도 하지 않았다.
|
||||
/// 윈도우의 거의 모든 프로그램이 지키는 규약이라, 사용자는 메뉴가 가리키는 대상을
|
||||
/// 확인할 생각조차 하지 않는다 — 20항목짜리 메뉴 전체의 신뢰가 이 한 걸음에 달려 있다.
|
||||
///
|
||||
/// <b>이미 선택 안에 있으면 선택을 유지한다</b> — 다중 선택을 우클릭해서 정렬하는 흐름을 깨면 안 된다.
|
||||
/// <c>e.Handled</c> 는 두지 않는다. 그대로 흘려보내야 기존 ContextMenu 가 평소처럼 열린다.
|
||||
/// </summary>
|
||||
private void OnRightButtonDown(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer || designer.IsTabOrderMode)
|
||||
{
|
||||
return; // 탭순서 편집 중에는 클릭이 순번 지정이라 선택을 건드리지 않는다
|
||||
}
|
||||
FocusCanvasHost();
|
||||
|
||||
var world = e.GetPosition(AssociatedObject);
|
||||
if (designer.HitTestControl(world) is { } hit)
|
||||
{
|
||||
if (!hit.IsSelected)
|
||||
{
|
||||
designer.Selection.Set(designer.GroupMatesOf(hit), hit);
|
||||
}
|
||||
return;
|
||||
}
|
||||
// 빈 곳이면 선택을 푼다 — 다만 <b>잠긴/숨긴 것 위</b>는 빈 곳이 아니다.
|
||||
// HitTestControl 이 그것들을 후보에서 아예 빼기 때문에(DesignerViewModel) 여기까지 오는데,
|
||||
// 잠긴 배경 괘선 위에서 우클릭할 때마다 선택이 사라지면 "왜 자꾸 풀리지"가 된다.
|
||||
if (!designer.AnyControlAt(world))
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
private void OnMouseMove(object sender, MouseEventArgs e)
|
||||
{
|
||||
if (Designer is not { } designer)
|
||||
|
||||
@@ -138,6 +138,58 @@ public static class LayerListBehavior
|
||||
{
|
||||
syncing.Remove(list);
|
||||
}
|
||||
RevealPrimary(list, designer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 캔버스에서 고른 것을 목록이 <b>따라오게</b> 한다 — 조상을 펴고 그 행으로 스크롤한다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 1,067개짜리 서식에서 종이 한가운데 컨트롤을 클릭하면
|
||||
/// 목록에는 아무 변화가 없는 경우가 흔했다 — 행이 뷰포트 밖이거나, 조상이 접혀 있어
|
||||
/// <b>행 자체가 만들어지지 않았거나</b>. 강조를 해도 눈에 안 보이니 선택이 안 된 것으로 읽힌다.
|
||||
/// 반대 방향(목록 더블클릭 → 캔버스)은 이미 잘 되어 있어 비대칭이 더 눈에 띄었다.
|
||||
///
|
||||
/// <b>펴기는 한 번만 시도한다.</b> 편 결과로 목록이 다시 만들어지면 재구성 이벤트가
|
||||
/// 이 경로를 한 번 더 부르는데, 그때는 행이 있으므로 스크롤만 하고 끝난다.
|
||||
/// 행이 끝내 없으면(검색·타입 필터에 걸려 빠진 경우) 아무것도 하지 않는다 —
|
||||
/// 필터를 사용자가 건 것이라 임의로 풀면 그쪽이 더 놀랍다.
|
||||
/// </summary>
|
||||
private static void RevealPrimary(ListBox list, DesignerViewModel designer)
|
||||
{
|
||||
if (designer.Selection.Primary is not { } primary)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (RowOf(list, primary) is { } row)
|
||||
{
|
||||
list.ScrollIntoView(row);
|
||||
return;
|
||||
}
|
||||
if (!designer.ExpandLayerPathTo(primary))
|
||||
{
|
||||
return; // 펼 것이 없는데도 행이 없다 = 필터에 걸린 것이다
|
||||
}
|
||||
// 재구성은 비동기로 도는 경로가 있다 — 한 틱 뒤에 다시 찾아본다
|
||||
list.Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
if (RowOf(list, primary) is { } revealed)
|
||||
{
|
||||
list.ScrollIntoView(revealed);
|
||||
}
|
||||
}), System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
/// <summary>이 컨트롤을 담고 있는 목록 행 — 없으면 null(접혔거나 필터에 걸렸다)</summary>
|
||||
private static object? RowOf(ListBox list, ControlViewModel control)
|
||||
{
|
||||
foreach (var row in list.Items)
|
||||
{
|
||||
if (DesignerViewModel.ControlsOfLayerRow(row).Contains(control))
|
||||
{
|
||||
return row;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>현재 선택으로부터 강조돼야 할 행들</summary>
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Input;
|
||||
using SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
namespace SheetMe.Designer.Behaviors;
|
||||
|
||||
/// <summary>
|
||||
/// 태그 행 인라인 완성 — 타이핑하면 목록이 뜨고 ↑↓/Enter/Esc 로 고른다.
|
||||
///
|
||||
/// <b>왜 이 화면인가.</b> 태그를 붙이는 일은 하루 수십~수백 번이고 운영 사용이 8,032건이다.
|
||||
/// 그런데 칩에 없는 태그는 이름을 16자 외워 치거나 1080×700 모달을 열어야 했고,
|
||||
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고 EMR 에서 조용히 빈 칸이 됐다</b>.
|
||||
///
|
||||
/// <b>새 패턴을 만들지 않는다.</b> 이 앱에는 이미 같은 것이 있다 —
|
||||
/// QueryEditorWindow 의 CompletionPopup(StaysOpen=True, ↑↓/Enter/Esc, 회귀 주석까지).
|
||||
/// 거기서 배운 것을 그대로 쓴다: <b>Popup 을 StaysOpen=False 로 두면 마우스를 캡처해
|
||||
/// 항목 클릭을 '바깥 클릭'으로 먹어 버린다</b>(그래서 눌러도 입력이 안 되고 사라졌다).
|
||||
///
|
||||
/// 부착 방식이 첨부 속성인 이유: 태그 행은 DataTemplate 안에 있어 x:Name 으로 잡을 수 없다.
|
||||
/// </summary>
|
||||
public static class TagCompletionBehavior
|
||||
{
|
||||
#region Member Fields
|
||||
public static readonly DependencyProperty EnabledProperty =
|
||||
DependencyProperty.RegisterAttached("Enabled", typeof(bool), typeof(TagCompletionBehavior),
|
||||
new PropertyMetadata(false, OnEnabledChanged));
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public static void SetEnabled(DependencyObject target, bool value) => target.SetValue(EnabledProperty, value);
|
||||
|
||||
public static bool GetEnabled(DependencyObject target) => (bool)target.GetValue(EnabledProperty);
|
||||
|
||||
private static void OnEnabledChanged(DependencyObject target, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (target is not TextBox box)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if ((bool)e.NewValue)
|
||||
{
|
||||
box.TextChanged += OnTextChanged;
|
||||
box.PreviewKeyDown += OnPreviewKeyDown;
|
||||
box.LostKeyboardFocus += OnLostFocus;
|
||||
return;
|
||||
}
|
||||
box.TextChanged -= OnTextChanged;
|
||||
box.PreviewKeyDown -= OnPreviewKeyDown;
|
||||
box.LostKeyboardFocus -= OnLostFocus;
|
||||
}
|
||||
|
||||
private static TagPickerRowViewModel? RowOf(object sender)
|
||||
=> (sender as FrameworkElement)?.DataContext as TagPickerRowViewModel;
|
||||
|
||||
private static void OnTextChanged(object sender, TextChangedEventArgs e)
|
||||
{
|
||||
if (RowOf(sender) is not { } row || sender is not TextBox box)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 코드가 값을 넣은 경우(칩 클릭·피커 확정)에는 목록을 열지 않는다 —
|
||||
// 방금 고른 것 위에 목록이 덮이면 다 골랐는데 가리는 셈이다.
|
||||
if (!box.IsKeyboardFocusWithin)
|
||||
{
|
||||
return;
|
||||
}
|
||||
row.BeginTyping();
|
||||
row.RefreshCompletions(box.Text);
|
||||
}
|
||||
|
||||
private static void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (RowOf(sender) is not { } row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
switch (e.Key)
|
||||
{
|
||||
case Key.Down when row.ShowCompletions:
|
||||
row.MoveCompletion(1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Up when row.ShowCompletions:
|
||||
row.MoveCompletion(-1);
|
||||
e.Handled = true;
|
||||
break;
|
||||
case Key.Enter when row.ShowCompletions:
|
||||
// 확정하면 Enter 를 여기서 먹는다 — 안 그러면 InspectorFieldBehavior 가
|
||||
// 같은 Enter 로 옛 텍스트를 한 번 더 커밋한다.
|
||||
e.Handled = row.CommitCompletion();
|
||||
if (e.Handled && sender is TextBox committed)
|
||||
{
|
||||
committed.Text = row.ValueText;
|
||||
committed.CaretIndex = committed.Text.Length;
|
||||
}
|
||||
break;
|
||||
case Key.Escape when row.ShowCompletions:
|
||||
row.EndTyping();
|
||||
e.Handled = true;
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private static void OnLostFocus(object sender, KeyboardFocusChangedEventArgs e)
|
||||
=> RowOf(sender)?.EndTyping();
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Data;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 선택지 목록의 표시 글자 — 빈 항목만 "여러 값"으로 바꾼다.
|
||||
///
|
||||
/// 값이 갈린 다중 선택에서 <see cref="ViewModels.Inspector.ChoiceRowViewModel.EnsureMixedPlaceholder"/> 가
|
||||
/// 목록 맨 앞에 빈 문자열 자리를 만든다. 그대로 그리면 목록에 <b>빈 줄</b>이 하나 보일 뿐이라
|
||||
/// 무엇인지 알 수 없고, 실수로 고르면 "값을 지우는 것"으로 읽힌다.
|
||||
/// 나머지 값은 레거시 원문 열거값 그대로 둔다 — 저장되는 문자열과 화면 글자가 같아야
|
||||
/// 사용자가 XML 을 열어 봤을 때 대조할 수 있다.
|
||||
/// </summary>
|
||||
public sealed class ChoiceLabelConverter : IValueConverter
|
||||
{
|
||||
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
{
|
||||
var text = value as string ?? string.Empty;
|
||||
return text.Length == 0 ? "여러 값" : text;
|
||||
}
|
||||
|
||||
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
|
||||
=> throw new NotSupportedException();
|
||||
}
|
||||
@@ -229,13 +229,28 @@
|
||||
<Rectangle Fill="{Binding LineBrush}" IsHitTestVisible="False"/>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 이미지 -->
|
||||
<!-- 이미지 — 해석기가 파일을 찾았으면 그 이미지를, 아니면 자리표시자를 그린다.
|
||||
종이(미리보기·인쇄)에서는 자리표시자 테두리가 보이면 안 되므로 이미지가 있으면
|
||||
테두리 없이 이미지만 남긴다. -->
|
||||
<DataTemplate DataType="{x:Type vmc:PictureBoxViewModel}">
|
||||
<Grid>
|
||||
<Border Background="#F5F7FA" BorderBrush="#C8D0DA" BorderThickness="1" IsHitTestVisible="False">
|
||||
<Border.Style>
|
||||
<Style TargetType="Border">
|
||||
<Setter Property="Visibility" Value="Visible"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasImage}" Value="True">
|
||||
<Setter Property="Visibility" Value="Collapsed"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</Border.Style>
|
||||
<TextBlock Text="{Binding Hint}" FontSize="10" Foreground="#8090A0"
|
||||
HorizontalAlignment="Center" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis"/>
|
||||
</Border>
|
||||
<Image Source="{Binding Image}" Stretch="{Binding ImageStretch}" IsHitTestVisible="False"/>
|
||||
</Grid>
|
||||
</DataTemplate>
|
||||
|
||||
<!-- 계산박스 -->
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 대화상자 크롬을 켠다 — <c>ThemedDialogWindow</c> 스타일이 <b>스타일 세터로</b> 걸어 준다.
|
||||
///
|
||||
/// <b>왜 첨부 속성인가.</b> 창 12종에 같은 타이틀바를 주려면 <see cref="ControlTemplate"/> 이 필요한데,
|
||||
/// 템플릿 안의 닫기 버튼은 <c>Click</c> 핸들러를 가질 수 없다(템플릿은 코드비하인드가 없다).
|
||||
/// 그래서 표준 <see cref="SystemCommands.CloseWindowCommand"/> 를 쓰고, 그 명령을 받을
|
||||
/// <see cref="CommandBinding"/> 을 여기서 창에 달아 준다. 창마다 코드를 넣지 않아도 된다.
|
||||
///
|
||||
/// <b>최대화 보정도 함께 건다.</b> <c>WindowStyle=None</c> 인 창은 최대화하는 순간
|
||||
/// 작업표시줄을 덮는다(메인 창에서 실제로 겪은 결함이다). 대화상자 대부분이 크기 조절 가능이라
|
||||
/// 타이틀바를 더블클릭하면 그 상태에 들어갈 수 있다 — 크롬을 켜는 이 자리에서 같이 막는다.
|
||||
/// </summary>
|
||||
public static class DialogChrome
|
||||
{
|
||||
#region Attached Properties
|
||||
/// <summary>크롬 사용 여부 — 스타일이 True 로 세팅한다</summary>
|
||||
public static readonly DependencyProperty EnabledProperty =
|
||||
DependencyProperty.RegisterAttached(
|
||||
"Enabled", typeof(bool), typeof(DialogChrome),
|
||||
new PropertyMetadata(false, OnEnabledChanged));
|
||||
|
||||
public static void SetEnabled(DependencyObject element, bool value)
|
||||
=> element.SetValue(EnabledProperty, value);
|
||||
|
||||
public static bool GetEnabled(DependencyObject element)
|
||||
=> (bool)element.GetValue(EnabledProperty);
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private static void OnEnabledChanged(DependencyObject sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (sender is not Window window || e.NewValue is not true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 같은 창에 두 번 걸리지 않게 한다 — 스타일이 다시 적용되는 경로가 있다
|
||||
foreach (CommandBinding existing in window.CommandBindings)
|
||||
{
|
||||
if (existing.Command == SystemCommands.CloseWindowCommand)
|
||||
{
|
||||
return;
|
||||
}
|
||||
}
|
||||
// 크롬은 <b>창마다 새로</b> 만든다. WindowChrome 은 Freezable 이고 창에 붙으면서
|
||||
// 스스로 속성을 고치므로, 한 인스턴스를 여러 창이 나눠 쓰면 두 번째 창부터
|
||||
// "읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다.
|
||||
// 그래서 XAML 스타일 세터로는 줄 수 없다(세터 값은 한 번 평가돼 공유된다).
|
||||
//
|
||||
// CaptionHeight 36 = 타이틀바 높이와 같아야 그 띠를 잡고 창을 끌 수 있다.
|
||||
// GlassFrameThickness 0,0,0,1 = DWM 그림자 유지.
|
||||
// ResizeBorderThickness 6 은 ResizeMode 가 허용할 때만 실제로 동작한다.
|
||||
System.Windows.Shell.WindowChrome.SetWindowChrome(window, new System.Windows.Shell.WindowChrome
|
||||
{
|
||||
CaptionHeight = 36,
|
||||
ResizeBorderThickness = new Thickness(6),
|
||||
GlassFrameThickness = new Thickness(0, 0, 0, 1),
|
||||
CornerRadius = new CornerRadius(0),
|
||||
UseAeroCaptionButtons = false,
|
||||
});
|
||||
|
||||
window.CommandBindings.Add(new CommandBinding(
|
||||
SystemCommands.CloseWindowCommand,
|
||||
(_, args) =>
|
||||
{
|
||||
// 창의 X 를 누른 것과 같아야 한다 — DialogResult 를 건드리지 않으므로
|
||||
// ShowDialog 는 false 를 돌려주고, 호출부의 '취소' 경로가 그대로 탄다
|
||||
SystemCommands.CloseWindow(window);
|
||||
args.Handled = true;
|
||||
}));
|
||||
|
||||
// 크기 조절 가능한 창만 최대화될 수 있다 — 그때 작업표시줄을 덮지 않게 한다
|
||||
if (window.ResizeMode is ResizeMode.CanResize or ResizeMode.CanResizeWithGrip)
|
||||
{
|
||||
if (window.IsInitialized)
|
||||
{
|
||||
Services.MaximizeToWorkArea.Attach(window);
|
||||
}
|
||||
else
|
||||
{
|
||||
window.SourceInitialized += (_, _) => Services.MaximizeToWorkArea.Attach(window);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
using System.Globalization;
|
||||
using System.Windows.Markup;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SheetMe.Designer.Controls;
|
||||
|
||||
/// <summary>
|
||||
/// 이 PC 에 설치된 글꼴 이름 목록 — 인스펙터 글꼴 칸의 선택지.
|
||||
///
|
||||
/// <b>한글 이름을 낸다.</b> 레거시가 저장하는 값이 그것이다 — 실사용 서식의 Font 속성은
|
||||
/// <c>"굴림, 11.25pt, style=Bold"</c> 처럼 GDI 가 주는 <b>지역화된</b> 이름을 담고 있다.
|
||||
/// WPF 의 <see cref="FontFamily.Source"/> 는 같은 글꼴을 "Gulim" 으로 주므로,
|
||||
/// 그걸 그대로 목록에 쓰면 사용자가 고른 순간 서식의 글꼴 이름이 영문으로 바뀐다.
|
||||
/// 레거시가 그 이름을 못 찾으면 기본 글꼴로 떨어지고, 그 사실은 EMR 에서만 드러난다.
|
||||
///
|
||||
/// <b>목록에 없는 이름도 지운다는 뜻이 아니다.</b> 병원 PC 와 이 PC 의 설치 글꼴은 다르다.
|
||||
/// 서식이 쓰는 글꼴이 여기 없을 수 있고, 그건 서식이 틀렸다는 뜻이 아니라
|
||||
/// <b>지금 이 화면이 병원과 다르게 보인다</b>는 뜻이다 — 그래서 값을 고치지 않고 알리기만 한다.
|
||||
/// </summary>
|
||||
public static class InstalledFontCatalog
|
||||
{
|
||||
#region Member Fields
|
||||
private static IReadOnlyList<string>? cached;
|
||||
private static HashSet<string>? installed;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>설치된 글꼴 이름(한글 우선) — 가나다·알파벳 순, 한 번만 훑는다</summary>
|
||||
public static IReadOnlyList<string> Families()
|
||||
{
|
||||
if (cached is not null)
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
var names = new SortedSet<string>(StringComparer.Create(CultureInfo.GetCultureInfo("ko-KR"), ignoreCase: true));
|
||||
foreach (var family in Fonts.SystemFontFamilies)
|
||||
{
|
||||
names.Add(DisplayNameOf(family));
|
||||
}
|
||||
cached = names.ToList();
|
||||
installed = new HashSet<string>(cached, StringComparer.OrdinalIgnoreCase);
|
||||
return cached;
|
||||
}
|
||||
|
||||
/// <summary>이 PC 에 있는 글꼴인가 — 없으면 화면과 병원 출력이 다르다는 신호다</summary>
|
||||
public static bool IsInstalled(string family)
|
||||
{
|
||||
if (family.Length == 0)
|
||||
{
|
||||
return true; // 미지정(상속)은 경고 대상이 아니다
|
||||
}
|
||||
Families();
|
||||
return installed!.Contains(family);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 글꼴의 표시 이름 — 한국어 이름이 있으면 그것, 없으면 원래 이름.
|
||||
///
|
||||
/// <c>FamilyNames</c> 는 글꼴이 스스로 담고 있는 언어별 이름표다. 한국어 글꼴은
|
||||
/// ko-KR 항목에 "굴림"·"맑은 고딕" 을 갖고 있고, 레거시(GDI)가 쓰는 이름이 바로 그것이다.
|
||||
/// </summary>
|
||||
private static string DisplayNameOf(FontFamily family)
|
||||
{
|
||||
var korean = XmlLanguage.GetLanguage("ko-KR");
|
||||
if (family.FamilyNames.TryGetValue(korean, out var name) && name.Length > 0)
|
||||
{
|
||||
return name;
|
||||
}
|
||||
// Source 는 "Gulim" 처럼 영문이거나, 조합 글꼴이면 "굴림, Arial" 같은 목록일 수 있다 —
|
||||
// 목록이면 첫 이름만 쓴다(레거시 Font.Name 도 하나의 이름이다)
|
||||
var source = family.Source;
|
||||
var comma = source.IndexOf(',');
|
||||
return comma < 0 ? source : source[..comma].Trim();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -168,6 +168,15 @@ public static class LucideIcons
|
||||
return new Viewbox { Width = size, Height = size, Child = canvas, Stretch = Stretch.Uniform, SnapsToDevicePixels = true };
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘 색 칠하기 — 키가 있으면 리소스 참조(테마 추종), 없으면 넘겨받은 브러시.
|
||||
///
|
||||
/// <b>둘 다 없으면 검정이 아니라 B.Muted 다.</b> 전에는 <c>Brushes.Black</c> 으로 떨어졌는데,
|
||||
/// 그건 다크 테마에서 <b>아이콘을 보이지 않게 만드는</b> 기본값이다.
|
||||
/// 실제로 제목줄의 환자 아이콘이 그 경로로 혼자만 까맣게 묻혀 있었고,
|
||||
/// 컴파일러가 CS8604 로 그 자리를 계속 가리키고 있었는데도 오래 남아 있었다.
|
||||
/// 기본값을 테마 토큰으로 두면 어느 호출부도 같은 함정에 빠질 수 없다.
|
||||
/// </summary>
|
||||
private static void Paint(Path path, DependencyProperty property, Brush? color, string? brushKey)
|
||||
{
|
||||
if (brushKey is not null)
|
||||
@@ -175,7 +184,12 @@ public static class LucideIcons
|
||||
path.SetResourceReference(property, brushKey);
|
||||
return;
|
||||
}
|
||||
path.SetValue(property, color ?? Brushes.Black);
|
||||
if (color is not null)
|
||||
{
|
||||
path.SetValue(property, color);
|
||||
return;
|
||||
}
|
||||
path.SetResourceReference(property, "B.Muted");
|
||||
}
|
||||
|
||||
private static (GeometryGroup Stroke, GeometryGroup Fill) Parse(string markup)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Data.Config;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.Services;
|
||||
@@ -105,6 +106,53 @@ internal sealed class FormDesignDataBusiness
|
||||
return DbStore().SaveDesign(document, AuditUid);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 제자리 정정 — 레거시 '기록지 수정'. 지금 쓰이는 SdgKey 를 유지한 채 디자인만 바꾼다.
|
||||
/// <see cref="SaveToDb"/> 와 같은 게이트를 쓴다 — 이쪽이 더 되돌리기 어려우므로 더 느슨할 이유가 없다.
|
||||
/// </summary>
|
||||
public decimal ModifyInDb(FormDocument document)
|
||||
{
|
||||
if (!CanSaveToDb)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
|
||||
}
|
||||
return DbStore().ModifyDesign(document, AuditUid);
|
||||
}
|
||||
|
||||
/// <summary>수정을 막아야 하는 새 컨트롤 이름들 — 비어 있으면 진행해도 된다</summary>
|
||||
public List<string> NewControlNames(FormDocument document) => DbStore().NewControlNames(document);
|
||||
|
||||
/// <summary>이 사용자가 수정을 쓸 수 있는가 — 레거시는 전산실에만 열어 두었다</summary>
|
||||
public static bool CanModify() => ModifyPolicy.CanModify(
|
||||
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
|
||||
HospitalStartDate);
|
||||
|
||||
public static string ModifyDenyReason() => ModifyPolicy.DenyReason(
|
||||
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
|
||||
|
||||
/// <summary>
|
||||
/// 이 사용자가 <b>실제 환자</b>로 미리보기를 할 수 있는가 — 인증만 확인한다.
|
||||
///
|
||||
/// 수정과 <b>다른</b> 문이다. 레거시가 부서로 가리는 툴은 TK_MODIFY 하나뿐이고
|
||||
/// TK_PREVIEW 에는 조건이 없다(frmSheetDesigner.vb:1030-1060).
|
||||
/// 여기에 부서 문을 걸면 서식을 만든 사람이 자기 서식을 검증할 수 없다.
|
||||
/// 안전장치는 막는 것이 아니라 <b>감사 로그</b>다.
|
||||
/// </summary>
|
||||
public static bool CanPreviewPatient() => PatientPreviewPolicy.CanPreviewPatient(
|
||||
UserSession.Current.HspCod, UserSession.Current.DepCod, UserSession.Current.UidCod,
|
||||
HospitalStartDate);
|
||||
|
||||
public static string PreviewPatientDenyReason() => PatientPreviewPolicy.DenyReason(
|
||||
UserSession.Current.HspCod, UserSession.Current.UidCod, HospitalStartDate);
|
||||
|
||||
/// <summary>
|
||||
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
|
||||
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
|
||||
/// 없는 값을 지어내 더 조이는 것보다, 레거시의 다수 경로와 같게 두고 이 사실을 적어 두는 쪽을 택한다.
|
||||
/// </summary>
|
||||
private const string HospitalStartDate = "";
|
||||
|
||||
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
|
||||
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
|
||||
|
||||
|
||||
@@ -0,0 +1,542 @@
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Media;
|
||||
using System.Windows.Media.Effects;
|
||||
using System.Windows.Media.Imaging;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// ClearType 이 실제로 꺼지는지 <b>화면을 찍어서</b> 잰다 (--cleartype).
|
||||
///
|
||||
/// <b>왜 화면을 찍는가.</b> <see cref="RenderTargetBitmap"/> 으로는 이 질문에 답할 수 없다 —
|
||||
/// 그 자체가 알파를 가진 중간 서피스라서 ClearType 이 무조건 꺼진다.
|
||||
/// 오프스크린으로 재면 Effect 가 있든 없든 양쪽 다 회색으로 나오고,
|
||||
/// 검사는 <b>통과하면서 아무것도 증명하지 못한다</b>.
|
||||
/// 그래서 진짜 화면(데스크톱 DC)을 BitBlt 으로 떠서 픽셀을 본다.
|
||||
///
|
||||
/// <b>무엇을 세는가.</b> ClearType 은 서브픽셀 렌더링이라 글리프 경계에 <b>채널이 어긋난 픽셀</b>
|
||||
/// (R≠G≠B, 이른바 색 번짐)을 남긴다. 회색 안티에일리어싱은 R=G=B 만 남긴다.
|
||||
/// 그래서 "채널 차이가 있는 픽셀 수"가 곧 ClearType 이 켜졌는지의 지표다.
|
||||
///
|
||||
/// <b>스스로 속지 않기 위한 장치.</b> 대조군(A: 아무 효과 없는 글자)에서 색 번짐이 0 이면
|
||||
/// 시스템 ClearType 이 꺼졌거나 캡처 경로가 틀린 것이므로 <b>판정 불가</b>로 끝낸다.
|
||||
/// 그 확인 없이 B 가 0 인 것을 근거로 삼으면 "둘 다 0"을 결론으로 착각한다.
|
||||
/// </summary>
|
||||
internal static class ClearTypeCheck
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>이 정도 채널 차이면 회색 AA 가 아니다 — 회색은 원리상 차이가 정확히 0 이다</summary>
|
||||
private const int ChannelSpread = 6;
|
||||
|
||||
/// <summary>글자가 그려진 픽셀로 볼 밝기 상한(흰 배경 기준)</summary>
|
||||
private const int InkLuma = 200;
|
||||
|
||||
private static readonly List<string> Lines = new();
|
||||
private static int failures;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public static int Run()
|
||||
{
|
||||
// F 표본이 앱의 실제 콤보 템플릿을 타야 하므로 테마를 실제로 적용한다
|
||||
Services.ThemeManager.Apply(true);
|
||||
|
||||
var window = BuildProbe(out var samples, out var popups);
|
||||
window.Show();
|
||||
|
||||
// ── 실제 창을 하나 잰다 ──
|
||||
// 위 표본들은 <b>기법</b>이 듣는지를 잰다(합성 도형). 그것이 통과해도
|
||||
// 실제 창에 기법을 <b>안 걸었으면</b> 글자는 계속 뭉개진다 — 실제로 그랬다.
|
||||
// 쿼리 편집 창은 카드에 그림자 Effect 가 있어 그 안 글자가 전부 중간 서피스를 탄다.
|
||||
// 카드 제목만 재던 것으로는 부족했다 — 제목은 켜졌는데 <b>SQL 본문</b>은 계속 꺼져 있었다.
|
||||
// 사람이 실제로 들여다보는 표면을 다 재야 한다: 본문·변수 목록·결과 요약.
|
||||
var real = OpenQueryEditorForClearType();
|
||||
if (real is null)
|
||||
{
|
||||
Lines.Add("SKIP G~J(실제 창) — 창을 띄우지 못해 판정 불가");
|
||||
}
|
||||
else
|
||||
{
|
||||
foreach (var (label, name) in new[]
|
||||
{
|
||||
("G(실제 창) 카드 제목", "SQL 편집기"),
|
||||
("H(실제 창) SQL 본문", "Highlight"),
|
||||
("I(실제 창) 치환 변수 목록", "VariableList"),
|
||||
})
|
||||
{
|
||||
var target = name == "SQL 편집기"
|
||||
? FindTextBlock(real, name)
|
||||
: real.FindName(name) as FrameworkElement;
|
||||
if (target is { ActualWidth: > 0 })
|
||||
{
|
||||
samples.Add((label, target, true));
|
||||
// ── 흐림의 <b>두 번째 축</b> ──
|
||||
// 색 번짐이 있어도(=ClearType 켜짐) TextFormattingMode 가 Ideal 이면
|
||||
// 글리프가 픽셀 격자에 안 맞아 11~14px 글자가 눌린 듯 흐려 보인다.
|
||||
// 색 번짐을 세는 방식으로는 두 값이 구분되지 않는다 — 그래서 두 번 놓쳤다.
|
||||
// 이건 픽셀이 아니라 <b>속성 값</b>으로 잠근다.
|
||||
var mode = TextOptions.GetTextFormattingMode(target);
|
||||
Check($"{label} → 화면 글자는 Display 로 그린다",
|
||||
mode == TextFormattingMode.Display, $"실제 {mode}");
|
||||
}
|
||||
else
|
||||
{
|
||||
Lines.Add($"SKIP {label} — 대상을 찾지 못해 판정 불가");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 창이 보이는 것과 합성기가 그려 낸 것은 다르다 — Loaded 직후 찍으면 빈 화면을 잰다.
|
||||
// (--modal-check 에서 BeginInvoke 가 Loaded 보다 먼저 돌아 0 을 세던 것과 같은 함정)
|
||||
var timer = new System.Windows.Threading.DispatcherTimer
|
||||
{
|
||||
Interval = TimeSpan.FromMilliseconds(600),
|
||||
};
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
try
|
||||
{
|
||||
Judge(samples);
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var popup in popups)
|
||||
{
|
||||
popup.IsOpen = false;
|
||||
}
|
||||
window.Close();
|
||||
Application.Current.Shutdown();
|
||||
}
|
||||
};
|
||||
timer.Start();
|
||||
|
||||
var dispatcherFrame = new System.Windows.Threading.DispatcherFrame();
|
||||
Application.Current.Exit += (_, _) => dispatcherFrame.Continue = false;
|
||||
System.Windows.Threading.Dispatcher.PushFrame(dispatcherFrame);
|
||||
|
||||
foreach (var line in Lines)
|
||||
{
|
||||
Console.Error.WriteLine(line);
|
||||
}
|
||||
Console.Error.WriteLine($"결과: 실패 {failures}건");
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 쿼리 편집 창을 띄워 카드 제목 글자를 잴 대상으로 내준다.
|
||||
///
|
||||
/// 합성 표본이 아니라 <b>배포되는 창</b>을 재야 한다 — 기법이 듣는 것과
|
||||
/// 그 기법을 이 창에 걸어 둔 것은 다른 일이고, 후자가 빠져 있었다.
|
||||
/// 창은 화면 안(0,0)에 띄운다. BitBlt 는 화면에 실제로 그려진 픽셀만 읽으므로
|
||||
/// 화면 밖(-10000)에 두면 아무것도 못 잰다.
|
||||
/// </summary>
|
||||
private static Window? OpenQueryEditorForClearType()
|
||||
{
|
||||
try
|
||||
{
|
||||
var editor = new Views.QueryEditorWindow("MDataTable1",
|
||||
"select PatChtNum, PatNam from P_PatInf where ROWNUM <= 1")
|
||||
{
|
||||
Left = 0,
|
||||
Top = 0,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
};
|
||||
editor.Show();
|
||||
editor.UpdateLayout();
|
||||
return editor;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Lines.Add($"SKIP G~I(실제 창) — {ex.GetType().Name}: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이 글자를 담은 TextBlock 을 찾는다 — 없으면 null</summary>
|
||||
private static FrameworkElement? FindTextBlock(DependencyObject root, string text)
|
||||
{
|
||||
if (root is System.Windows.Controls.TextBlock block
|
||||
&& block.Text.Contains(text, StringComparison.Ordinal))
|
||||
{
|
||||
return block;
|
||||
}
|
||||
var count = System.Windows.Media.VisualTreeHelper.GetChildrenCount(root);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
if (FindTextBlock(System.Windows.Media.VisualTreeHelper.GetChild(root, i), text)
|
||||
is { } found)
|
||||
{
|
||||
return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>대조군이 먼저 서야 나머지 숫자가 뜻을 갖는다</summary>
|
||||
private static void Judge(List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples)
|
||||
{
|
||||
var measured = samples
|
||||
.Select(s => (s.Name, s.ExpectClearType, Result: Measure(s.Target)))
|
||||
.ToList();
|
||||
|
||||
foreach (var (name, _, result) in measured)
|
||||
{
|
||||
// 잰 사각형을 함께 찍는다 — 두 표본이 같은 자리를 재고 있으면 숫자가 우연히 같아지는데,
|
||||
// 좌표를 안 찍으면 그것을 "결과가 같다"로 읽는다(실제로 한 번 그렇게 읽었다).
|
||||
Lines.Add($" {name,-40} 색번짐 {result.Fringed,6} / 글자픽셀 {result.Ink,6} @ {result.Rect}");
|
||||
}
|
||||
|
||||
var control = measured[0];
|
||||
if (control.Result.Fringed == 0)
|
||||
{
|
||||
Lines.Add("판정 불가 — 대조군에서 색 번짐이 0 이다.");
|
||||
Lines.Add(" 시스템 ClearType 이 꺼져 있거나 캡처가 화면을 못 읽고 있다.");
|
||||
Lines.Add(" 이 상태에서 나머지 0 은 근거가 되지 않는다.");
|
||||
failures++;
|
||||
return;
|
||||
}
|
||||
Lines.Add($"대조군 확인 — 색 번짐 {control.Result.Fringed}건. 이제 나머지 0 은 뜻이 있다.");
|
||||
|
||||
foreach (var (name, expect, result) in measured)
|
||||
{
|
||||
var on = result.Fringed > 0;
|
||||
Check($"{name} → ClearType {(expect ? "켜짐" : "꺼짐")}", on == expect,
|
||||
$"실제 색번짐 {result.Fringed}건");
|
||||
}
|
||||
}
|
||||
|
||||
private static int Spread(int r, int g, int b)
|
||||
=> Math.Max(r, Math.Max(g, b)) - Math.Min(r, Math.Min(g, b));
|
||||
|
||||
private static void Check(string label, bool ok, string detail = "")
|
||||
{
|
||||
Lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? $" — {detail}" : string.Empty)}");
|
||||
if (!ok)
|
||||
{
|
||||
failures++;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Probe
|
||||
/// <summary>
|
||||
/// 같은 글자를 다섯 조건으로 놓는다. 배경은 전부 <b>불투명 흰색</b>이어야 한다 —
|
||||
/// ClearType 은 뒤에 무엇이 있는지 알아야 서브픽셀을 계산할 수 있어서,
|
||||
/// 투명 배경에서는 효과와 무관하게 회색으로 떨어진다.
|
||||
/// </summary>
|
||||
private static Window BuildProbe(
|
||||
out List<(string Name, FrameworkElement Target, bool ExpectClearType)> samples,
|
||||
out List<Popup> popups)
|
||||
{
|
||||
var shadow = new DropShadowEffect
|
||||
{
|
||||
BlurRadius = 26, ShadowDepth = 5, Direction = 270,
|
||||
Opacity = 0.14, Color = Colors.Black, RenderingBias = RenderingBias.Quality,
|
||||
};
|
||||
|
||||
var plain = Text();
|
||||
var effected = Text();
|
||||
var hintOnEffected = Text();
|
||||
var hintInside = Text();
|
||||
var inPopup = Text();
|
||||
var inPopupHinted = Text();
|
||||
|
||||
var effectedHost = Card(effected);
|
||||
effectedHost.Effect = shadow;
|
||||
|
||||
// 변형 1 — 힌트를 Effect 가 걸린 요소 자신에게
|
||||
var hostA = Card(hintOnEffected);
|
||||
hostA.Effect = shadow.Clone();
|
||||
RenderOptions.SetClearTypeHint(hostA, ClearTypeHint.Enabled);
|
||||
|
||||
// 변형 2 — 힌트를 중간 서피스 <b>안쪽</b>의 불투명 배경 요소에.
|
||||
// 문서가 "불투명 배경이 있는 지점"이라고만 말해서 어느 쪽인지 재 봐야 안다.
|
||||
var inner = new Border { Background = Brushes.White, Child = hintInside };
|
||||
RenderOptions.SetClearTypeHint(inner, ClearTypeHint.Enabled);
|
||||
var hostB = Card(inner);
|
||||
hostB.Effect = shadow.Clone();
|
||||
|
||||
// F — 합성 표본이 아니라 <b>앱의 실제 콤보 템플릿</b>. ComboBox 스타일이 암시라 그대로 태워진다.
|
||||
// 이 표본이 있어야 "플랫폼이 그렇다"에서 "이 앱이 그렇다"로 넘어갈 수 있다.
|
||||
// 드롭다운은 AllowsTransparency 팝업 + FloatShadow Effect 가 겹쳐 걸린 자리다(DesignerTheme.xaml:548,553).
|
||||
var themedItem = Text();
|
||||
var combo = new ComboBox { Width = 300, HorizontalAlignment = HorizontalAlignment.Left };
|
||||
combo.Items.Add(themedItem);
|
||||
// 선택하지 않는다 — 선택 강조가 배경을 유채색으로 만들면 위 판정 장치가 이 표본을 거부한다
|
||||
combo.SelectedIndex = -1;
|
||||
|
||||
var anchor = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
var anchorHinted = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
var anchorStacked = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
var anchorSplit = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
var anchorScrolled = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
var anchorInnermost = new Border { Width = 300, Height = 20, Background = Brushes.White };
|
||||
|
||||
var popupPlain = LayeredPopup(inPopup, anchor);
|
||||
var popupHinted = LayeredPopup(inPopupHinted, anchorHinted);
|
||||
RenderOptions.SetClearTypeHint((FrameworkElement)popupHinted.Child, ClearTypeHint.Enabled);
|
||||
|
||||
// G — 앱의 현재 구조: 레이어드 팝업 <b>안에</b> Effect 가 걸린 Border, 그 안에 힌트.
|
||||
// 중간 서피스가 두 겹이면 힌트가 듣는지 재 본다.
|
||||
var stackedText = Text();
|
||||
var stackedInner = new Border { Background = Brushes.White, Child = stackedText };
|
||||
RenderOptions.SetClearTypeHint(stackedInner, ClearTypeHint.Enabled);
|
||||
var stackedOuter = new Border { Background = Brushes.White, Child = stackedInner, Effect = shadow.Clone() };
|
||||
var popupStacked = LayeredPopup(stackedOuter, anchorStacked, wrap: false);
|
||||
|
||||
// H — 후보 수리: 그림자를 <b>글자가 없는 형제</b>로 떼어낸다.
|
||||
// 그러면 Effect 의 중간 서피스에 글자가 안 들어가고, 남는 것은 레이어드 창 한 겹뿐이라
|
||||
// E 와 같은 조건이 된다.
|
||||
var splitText = Text();
|
||||
var shadowOnly = new Border { Background = Brushes.White, Effect = shadow.Clone() };
|
||||
var splitContent = new Border { Background = Brushes.White, Child = splitText };
|
||||
RenderOptions.SetClearTypeHint(splitContent, ClearTypeHint.Enabled);
|
||||
var splitHost = new Grid();
|
||||
splitHost.Children.Add(shadowOnly);
|
||||
splitHost.Children.Add(splitContent);
|
||||
var popupSplit = LayeredPopup(splitHost, anchorSplit, wrap: false);
|
||||
|
||||
// I — G 와 똑같지만 힌트와 글자 사이에 ScrollViewer 를 끼운다.
|
||||
// 앱 콤보 드롭다운이 바로 이 모양인데 G 는 되고 F 는 안 되니, 남은 차이가 이것뿐이다.
|
||||
var scrolledText = Text();
|
||||
var scrolledInner = new Grid { Background = Brushes.White };
|
||||
RenderOptions.SetClearTypeHint(scrolledInner, ClearTypeHint.Enabled);
|
||||
scrolledInner.Children.Add(new ScrollViewer { Content = scrolledText });
|
||||
var scrolledOuter = new Border
|
||||
{
|
||||
Background = Brushes.White, Child = scrolledInner, Effect = shadow.Clone(),
|
||||
};
|
||||
var popupScrolled = LayeredPopup(scrolledOuter, anchorScrolled, wrap: false);
|
||||
|
||||
// J — 힌트를 ScrollViewer <b>안쪽</b>(가장 안쪽 불투명 배경)에 건다.
|
||||
var innermostText = Text();
|
||||
var innermost = new Border { Background = Brushes.White, Child = innermostText };
|
||||
RenderOptions.SetClearTypeHint(innermost, ClearTypeHint.Enabled);
|
||||
var innerOuter = new Border
|
||||
{
|
||||
Background = Brushes.White,
|
||||
Child = new ScrollViewer { Content = innermost },
|
||||
Effect = shadow.Clone(),
|
||||
};
|
||||
var popupInnermost = LayeredPopup(innerOuter, anchorInnermost, wrap: false);
|
||||
|
||||
var stack = new StackPanel { Margin = new Thickness(20) };
|
||||
stack.Children.Add(Card(plain));
|
||||
stack.Children.Add(effectedHost);
|
||||
stack.Children.Add(hostA);
|
||||
stack.Children.Add(hostB);
|
||||
stack.Children.Add(anchor);
|
||||
// 팝업이 아래로 펼쳐질 자리 — 앵커끼리 겹치면 두 팝업이 같은 픽셀을 재게 된다
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(anchorHinted);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(combo);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(anchorStacked);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(anchorSplit);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(anchorScrolled);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
stack.Children.Add(anchorInnermost);
|
||||
stack.Children.Add(new Border { Height = 46 });
|
||||
|
||||
var window = new Window
|
||||
{
|
||||
Title = "ClearType 검사",
|
||||
Width = 480, Height = 1040,
|
||||
Left = 60, Top = 60,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
Background = Brushes.White,
|
||||
Content = stack,
|
||||
Topmost = true,
|
||||
};
|
||||
window.ContentRendered += (_, _) =>
|
||||
{
|
||||
popupPlain.IsOpen = true;
|
||||
popupHinted.IsOpen = true;
|
||||
popupStacked.IsOpen = true;
|
||||
popupSplit.IsOpen = true;
|
||||
popupScrolled.IsOpen = true;
|
||||
popupInnermost.IsOpen = true;
|
||||
combo.IsDropDownOpen = true;
|
||||
};
|
||||
|
||||
// 기대값은 <b>예측이 아니라 측정 결과</b>다. C1 과 E 는 내가 예상한 것과 반대로 나왔고
|
||||
// 그 사실이 곧 수리 방법을 정한다 —
|
||||
// 힌트는 Effect 가 걸린 요소 자신에게 걸면 듣지 않고(C1), 그 <b>안쪽</b> 불투명 배경에 걸면 듣는다(C2).
|
||||
// 레이어드 팝업도 자식 불투명 배경에 걸면 되살아난다(E) — AllowsTransparency 를 끌 필요가 없다.
|
||||
samples = new()
|
||||
{
|
||||
("A 아무것도 없음 (대조군)", plain, true),
|
||||
("B Effect (그림자)", effected, false),
|
||||
("C1 Effect + 힌트를 그 요소에", hintOnEffected, false),
|
||||
("C2 Effect + 힌트를 안쪽 배경에", hintInside, true),
|
||||
("D Popup AllowsTransparency", inPopup, false),
|
||||
("E Popup + 힌트를 자식 배경에", inPopupHinted, true),
|
||||
("F 앱 콤보 드롭다운 (실제 템플릿)", themedItem, true),
|
||||
("G 팝업+Effect 두 겹 + 힌트", stackedText, true),
|
||||
("H 팝업, 그림자를 형제로 분리 + 힌트", splitText, true),
|
||||
("I 힌트 바깥에 ScrollViewer", scrolledText, false),
|
||||
("J 힌트를 ScrollViewer 안쪽에", innermostText, true),
|
||||
};
|
||||
popups = new() { popupPlain, popupHinted, popupStacked, popupSplit, popupScrolled, popupInnermost };
|
||||
return window;
|
||||
}
|
||||
|
||||
/// <summary>글리프가 많고 획이 가는 글자 — 색 번짐이 가장 잘 드러난다</summary>
|
||||
private static TextBlock Text() => new()
|
||||
{
|
||||
Text = "가나다라마 환자명 일자 ABCDEFG 0123456789",
|
||||
FontFamily = new FontFamily("Malgun Gothic"),
|
||||
FontSize = 13,
|
||||
Foreground = Brushes.Black,
|
||||
Margin = new Thickness(8, 5, 8, 5),
|
||||
};
|
||||
|
||||
private static Border Card(UIElement child) => new()
|
||||
{
|
||||
Background = Brushes.White,
|
||||
Margin = new Thickness(0, 0, 0, 18),
|
||||
Child = child,
|
||||
};
|
||||
|
||||
private static Popup LayeredPopup(UIElement child, UIElement target, bool wrap = true) => new()
|
||||
{
|
||||
PlacementTarget = target,
|
||||
Placement = PlacementMode.Bottom,
|
||||
AllowsTransparency = true,
|
||||
StaysOpen = true,
|
||||
Child = wrap ? new Border { Background = Brushes.White, Child = child } : child,
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region Screen capture
|
||||
/// <summary>
|
||||
/// 이 요소가 실제로 그려진 화면 조각을 떠서, 채널이 어긋난 픽셀과 글자 픽셀을 센다.
|
||||
/// </summary>
|
||||
private static (int Fringed, int Ink, string Rect) Measure(FrameworkElement element)
|
||||
{
|
||||
if (!element.IsVisible || element.ActualWidth < 1 || element.ActualHeight < 1)
|
||||
{
|
||||
return (0, 0, "보이지 않음");
|
||||
}
|
||||
// PointToScreen 은 물리 화면 픽셀을 준다 — 배율이 걸려 있어도 그대로 쓸 수 있다
|
||||
var topLeft = element.PointToScreen(new Point(0, 0));
|
||||
var bottomRight = element.PointToScreen(new Point(element.ActualWidth, element.ActualHeight));
|
||||
var x = (int)Math.Floor(Math.Min(topLeft.X, bottomRight.X));
|
||||
var y = (int)Math.Floor(Math.Min(topLeft.Y, bottomRight.Y));
|
||||
var width = (int)Math.Ceiling(Math.Abs(bottomRight.X - topLeft.X));
|
||||
var height = (int)Math.Ceiling(Math.Abs(bottomRight.Y - topLeft.Y));
|
||||
if (width < 1 || height < 1)
|
||||
{
|
||||
return (0, 0, "크기 0");
|
||||
}
|
||||
|
||||
var pixels = Grab(x, y, width, height);
|
||||
if (pixels is null)
|
||||
{
|
||||
return (0, 0, "캡처 실패");
|
||||
}
|
||||
|
||||
// 배경을 먼저 정한다 — 가장 많이 나온 색이 배경이다.
|
||||
//
|
||||
// <b>왜 필요한가.</b> "채널 차이 = 서브픽셀 AA"는 배경이 무채색일 때만 성립한다.
|
||||
// 유채색 배경(예: 선택된 콤보 항목의 B.Sel #C2E2FF, 채널 차이 61)에서는
|
||||
// 글자와 무관하게 거의 모든 픽셀이 색 번짐으로 잡혀서 <b>ClearType 이 꺼져 있어도 통과한다</b>.
|
||||
// 실제로 그렇게 통과시켰다 — 색번짐 4359 인데 글자픽셀은 1075 였다.
|
||||
var histogram = new Dictionary<int, int>();
|
||||
for (var i = 0; i + 3 < pixels.Length; i += 4)
|
||||
{
|
||||
var key = (pixels[i + 2] << 16) | (pixels[i + 1] << 8) | pixels[i];
|
||||
histogram[key] = histogram.TryGetValue(key, out var seen) ? seen + 1 : 1;
|
||||
}
|
||||
var background = histogram.OrderByDescending(pair => pair.Value).First().Key;
|
||||
int bgR = (background >> 16) & 0xFF, bgG = (background >> 8) & 0xFF, bgB = background & 0xFF;
|
||||
if (Spread(bgR, bgG, bgB) > ChannelSpread)
|
||||
{
|
||||
return (0, 0, $"{x},{y} 배경이 유채색({bgR},{bgG},{bgB}) — 판정 불가");
|
||||
}
|
||||
|
||||
var fringed = 0;
|
||||
var ink = 0;
|
||||
for (var i = 0; i + 3 < pixels.Length; i += 4)
|
||||
{
|
||||
int b = pixels[i], g = pixels[i + 1], r = pixels[i + 2];
|
||||
// 배경 그 자체는 세지 않는다 — 글리프가 만든 픽셀만 본다
|
||||
if ((r == bgR && g == bgG && b == bgB) is false && Spread(r, g, b) > ChannelSpread)
|
||||
{
|
||||
fringed++;
|
||||
}
|
||||
// Rec.601 근사 — 정수만으로 충분하다
|
||||
if ((r * 299 + g * 587 + b * 114) / 1000 < InkLuma)
|
||||
{
|
||||
ink++;
|
||||
}
|
||||
}
|
||||
return (fringed, ink, $"{x},{y} {width}x{height} 배경({bgR},{bgG},{bgB})");
|
||||
}
|
||||
|
||||
/// <summary>데스크톱 DC 에서 사각형 하나를 떠 BGRA 바이트로 돌려준다</summary>
|
||||
private static byte[]? Grab(int x, int y, int width, int height)
|
||||
{
|
||||
var screen = GetDC(IntPtr.Zero);
|
||||
if (screen == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var memory = IntPtr.Zero;
|
||||
var bitmap = IntPtr.Zero;
|
||||
try
|
||||
{
|
||||
memory = CreateCompatibleDC(screen);
|
||||
bitmap = CreateCompatibleBitmap(screen, width, height);
|
||||
if (memory == IntPtr.Zero || bitmap == IntPtr.Zero)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var previous = SelectObject(memory, bitmap);
|
||||
// SRCCOPY — 합성이 끝난 화면을 그대로 가져온다
|
||||
if (!BitBlt(memory, 0, 0, width, height, screen, x, y, 0x00CC0020))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
SelectObject(memory, previous);
|
||||
|
||||
var source = System.Windows.Interop.Imaging.CreateBitmapSourceFromHBitmap(
|
||||
bitmap, IntPtr.Zero, Int32Rect.Empty, BitmapSizeOptions.FromEmptyOptions());
|
||||
var converted = new FormatConvertedBitmap(source, PixelFormats.Bgra32, null, 0);
|
||||
var stride = converted.PixelWidth * 4;
|
||||
var buffer = new byte[stride * converted.PixelHeight];
|
||||
converted.CopyPixels(buffer, stride, 0);
|
||||
return buffer;
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (bitmap != IntPtr.Zero)
|
||||
{
|
||||
DeleteObject(bitmap);
|
||||
}
|
||||
if (memory != IntPtr.Zero)
|
||||
{
|
||||
DeleteDC(memory);
|
||||
}
|
||||
ReleaseDC(IntPtr.Zero, screen);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern IntPtr GetDC(IntPtr hwnd);
|
||||
[DllImport("user32.dll")] private static extern int ReleaseDC(IntPtr hwnd, IntPtr dc);
|
||||
[DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleDC(IntPtr dc);
|
||||
[DllImport("gdi32.dll")] private static extern IntPtr CreateCompatibleBitmap(IntPtr dc, int width, int height);
|
||||
[DllImport("gdi32.dll")] private static extern IntPtr SelectObject(IntPtr dc, IntPtr obj);
|
||||
[DllImport("gdi32.dll")] private static extern bool DeleteObject(IntPtr obj);
|
||||
[DllImport("gdi32.dll")] private static extern bool DeleteDC(IntPtr dc);
|
||||
[DllImport("gdi32.dll")] private static extern bool BitBlt(IntPtr dest, int x, int y, int width, int height,
|
||||
IntPtr source, int sourceX, int sourceY, uint rop);
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -53,6 +53,13 @@ public static class DialogShots
|
||||
: window.Background?.ToString() ?? "(null)";
|
||||
var styled = window.Style is not null ? "스타일적용" : "스타일없음";
|
||||
lines.Add($"OK {name}-{suffix} 배경={background} {styled}");
|
||||
// 그림은 증거만 만든다 — 잘렸는지는 값으로 단정해야 CI 가 잡는다
|
||||
var violations = OverflowCheck.Inspect(window, out var inspected);
|
||||
lines.Add($" 글자 {inspected}개 검사, 넘침 {violations.Count}건");
|
||||
foreach (var violation in violations)
|
||||
{
|
||||
lines.Add($"FAIL 넘침 {name}-{suffix} {violation}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -63,6 +70,8 @@ public static class DialogShots
|
||||
|
||||
lines.Add(string.Empty);
|
||||
lines.AddRange(StyleSelfCheck());
|
||||
lines.Add(string.Empty);
|
||||
lines.AddRange(OverflowCheck.SelfCheck());
|
||||
|
||||
File.WriteAllText(Path.Combine(outputDirectory, "_report.txt"),
|
||||
string.Join(Environment.NewLine, lines));
|
||||
@@ -111,7 +120,22 @@ public static class DialogShots
|
||||
var designer = SampleDesigner();
|
||||
return new List<(string, Func<Window>)>
|
||||
{
|
||||
("01-sheet-open", () => new Views.SheetOpenDialogView(_ => SampleSheets())),
|
||||
// 메인 셸 — 이 게이트에 <b>아예 없었다</b>. 사용자가 하루 종일 보는 화면만 자동 검증 밖이었다.
|
||||
// 두 크기로 찍는다: 기본 창(1440×920)과 현장 단말 최대화 크기(1920×1032).
|
||||
// 현장이 1920×1080 이상뿐이라는 답을 받았으므로 그 사각형이 실제 기준이다.
|
||||
("00-main-default", () => NewMainShell(1440, 920)),
|
||||
("00b-main-1920", () => NewMainShell(1920, 1032)),
|
||||
// 도구 상자 탭은 타일 라벨이 좁은 2열에 들어가므로 잘림이 가장 잘 나는 자리다
|
||||
("00c-main-toolbox", () => NewMainShell(1440, 920, leftTab: 2)),
|
||||
// 'DB에서 열기' 대화상자를 지웠다. 그 자리를 좌측 서식 목록 샷이 대신한다 —
|
||||
// 안 그러면 서식명 잘림을 잡을 자리가 게이트에서 통째로 사라진다.
|
||||
("01-sheet-list", () => NewMainShell(1440, 920, leftTab: 0, sheets: SampleSheets())),
|
||||
// 문서 없는 시작 화면 — '새 서식'을 없앤 뒤로는 이것이 <b>기본 상태</b>다.
|
||||
// 여기가 대조군이다: 00-main-default 는 문서가 있어 인스펙터가 보여야 하고,
|
||||
// 이 샷은 없어서 접혀야 한다. 접히지 않으면 DataContext 가 null 인 채로
|
||||
// 안쪽 Visibility 바인딩이 전부 실패해 정렬 바·간격값이 남는다(WPF 는 실패를 Visible 로 떨군다).
|
||||
("00d-main-empty", () => NewMainShell(1440, 920, leftTab: 0,
|
||||
sheets: SampleSheets(), withDocument: false)),
|
||||
("02-sheet-history", () => new Views.SheetHistoryDialogView("S999", "표본 서식", SampleVersions())),
|
||||
("03-query-editor", () => NewQueryEditor(designer,
|
||||
"SELECT ChtNum, PatNam FROM P_PatMst\nWHERE ChtNum = <<M.CMM.HISOperatingInfo.bzPatientInfo.ChtNum>>")),
|
||||
@@ -131,6 +155,12 @@ public static class DialogShots
|
||||
("06-font-manager", () => new Views.FontManagerDialogView(designer)),
|
||||
("07-register-sheet", () => new Views.RegisterSheetDialogView("S999", "표본 서식")),
|
||||
("08-preview", () => new Views.PreviewWindow(designer)),
|
||||
// 환자 선택 — 목록 둘이 빈 상태로 찍힌다(진단은 DB 없이도 같은 답을 내야 한다).
|
||||
// 빈 상태의 레이아웃도 회귀 대상이다: 목록이 비면 창이 무너지는 배치가 흔하다.
|
||||
("08b-patient-picker", () => new Views.PatientPickerDialogView()),
|
||||
// 데이터소스 결과 — 실패한 상태로 찍는다. 성공 화면만 회귀 대상으로 두면
|
||||
// 정작 사람이 오래 들여다보는(= 값이 안 나올 때) 화면이 검사에서 빠진다.
|
||||
("08c-datasource-result", () => new Views.DataSourceResultDialogView(SampleRunner())),
|
||||
// 인스펙터는 UserControl 이라 창이 없다 — 실폭 300px 호스트 창에 담아 실제 배치를 찍는다.
|
||||
// 속성 패널 레이아웃 회귀를 눈으로 확인할 수 있는 유일한 자동 경로다.
|
||||
("09-inspector-label", () => HostInspector(designer, "Label1")),
|
||||
@@ -194,6 +224,72 @@ public static class DialogShots
|
||||
/// MainViewModel 을 통째로 만들면 DB 조회가 딸려 오므로, 패널이 읽는 <c>CurrentDesigner</c> 하나만
|
||||
/// 들고 있는 최소 DataContext 를 세워 준다.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 메인 셸을 주어진 크기로 만든다.
|
||||
///
|
||||
/// Loaded 커맨드(서식 목록 DB 조회)는 <see cref="StartupArguments.DiagnosticMode"/> 때문에 돌지 않으므로
|
||||
/// 접속 없이도 같은 결과가 나온다. 문서 한 장을 넣어 캔버스·레이어·인스펙터가 빈 상태가 아니게 한다 —
|
||||
/// 빈 껍데기를 찍으면 잘림을 볼 수 없다.
|
||||
/// </summary>
|
||||
private static Window NewMainShell(double width, double height, int leftTab = 1,
|
||||
List<SheetSummary>? sheets = null, bool withDocument = true)
|
||||
{
|
||||
var window = new Views.MainView
|
||||
{
|
||||
Width = width,
|
||||
Height = height,
|
||||
Left = -10000,
|
||||
Top = -10000,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
};
|
||||
if (window.DataContext is not MainViewModel shell)
|
||||
{
|
||||
return window;
|
||||
}
|
||||
if (withDocument)
|
||||
{
|
||||
shell.AttachBlankForDiagnostics();
|
||||
}
|
||||
shell.SelectedLeftTabIndex = leftTab;
|
||||
// 진단 모드는 Loaded 커맨드를 안 돌리므로 목록이 비어 있다 —
|
||||
// 빈 목록을 찍으면 서식명 잘림을 볼 수 없다. 표본을 직접 넣는다.
|
||||
if (sheets is not null)
|
||||
{
|
||||
shell.SheetList.Clear();
|
||||
foreach (var sheet in sheets)
|
||||
{
|
||||
shell.SheetList.Add(sheet);
|
||||
}
|
||||
}
|
||||
|
||||
// 컨트롤을 놓고 하나를 고른다 — 이걸 안 하면 인스펙터가 비어 있고 레이어에 행이 없어서
|
||||
// 검사할 글자가 31개밖에 안 나온다(빈 껍데기를 찍으면 잘림을 볼 수 없다).
|
||||
// 이름이 긴 컨트롤을 섞는다 — 좁은 칸에서 잘리는 것은 짧은 이름이 아니라 긴 이름이다.
|
||||
if (shell.CurrentDesigner is { } designer)
|
||||
{
|
||||
foreach (var type in new[] { "Label", "TextBox", "CheckBox", "MDataTable", "MPictureBox" })
|
||||
{
|
||||
designer.AddPaletteItemAtCenter(type);
|
||||
}
|
||||
foreach (var control in designer.Pages[0].Controls)
|
||||
{
|
||||
if (control.Type == "Label")
|
||||
{
|
||||
control.Model.Id = "아주긴이름을가진라벨컨트롤_LAB_00123";
|
||||
control.Model.Props.SetText("Text",
|
||||
"잘림을 보려면 충분히 긴 문구가 필요하다 — 간호·간병통합서비스 병동");
|
||||
control.NotifyAllChanged();
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (designer.Pages[0].Controls.FirstOrDefault() is { } first)
|
||||
{
|
||||
designer.Selection.SetSingle(first);
|
||||
}
|
||||
}
|
||||
return window;
|
||||
}
|
||||
|
||||
private static Window HostLayerPanel(DesignerViewModel designer, LayerShotMode mode)
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
@@ -294,18 +390,26 @@ public static class DialogShots
|
||||
// 레이아웃·비동기 로딩(Loaded 핸들러)이 한 바퀴 돌게 한다
|
||||
Pump();
|
||||
|
||||
// 창 자체를 렌더한다 — Content 만 찍으면 Window.Background(B.AppBg)가 빠져 투명이 되고,
|
||||
// PNG 에서 검정으로 저장돼 "다크처럼 보이는" 착시가 생긴다(라이트 결함이 가려진다).
|
||||
// <b>창 전체</b>를 찍는다 — Content 만 찍으면 타이틀바가 통째로 빠진다.
|
||||
//
|
||||
// 전에는 Content 를 찍었다. 그래서 창 크롬을 통일하기 전까지 이 진단은
|
||||
// "열한 창이 윈도우 기본 타이틀바를 달고 뜬다"는 사실을 <b>한 번도 보여 준 적이 없었다</b> —
|
||||
// 고쳐야 할 바로 그 부분에 눈이 없었던 셈이다. 이제 커스텀 타이틀바는 창의 Template
|
||||
// 안에 있으므로 Content 만 찍으면 여전히 안 보인다.
|
||||
//
|
||||
// 배경을 먼저 칠하는 것은 그대로 둔다 — 투명 픽셀이 PNG 에서 검정으로 저장되면
|
||||
// 라이트 테마 결함이 "다크처럼 보이는" 착시에 가려진다.
|
||||
var content = (FrameworkElement)window.Content;
|
||||
var width = (int)Math.Ceiling(content.ActualWidth > 0 ? content.ActualWidth : window.Width);
|
||||
var height = (int)Math.Ceiling(content.ActualHeight > 0 ? content.ActualHeight : window.Height);
|
||||
var root = (FrameworkElement?)window.Template?.FindName("PART_Root", window) ?? content;
|
||||
var width = (int)Math.Ceiling(window.ActualWidth > 0 ? window.ActualWidth : window.Width);
|
||||
var height = (int)Math.Ceiling(window.ActualHeight > 0 ? window.ActualHeight : window.Height);
|
||||
var bitmap = new RenderTargetBitmap(Math.Max(1, width), Math.Max(1, height), 96, 96, PixelFormats.Pbgra32);
|
||||
|
||||
var drawing = new DrawingVisual();
|
||||
using (var context = drawing.RenderOpen())
|
||||
{
|
||||
context.DrawRectangle(window.Background ?? Brushes.Transparent, null, new Rect(0, 0, width, height));
|
||||
context.DrawRectangle(new VisualBrush(content) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
|
||||
context.DrawRectangle(new VisualBrush(root) { Stretch = Stretch.None, AlignmentX = AlignmentX.Left, AlignmentY = AlignmentY.Top },
|
||||
null, new Rect(0, 0, width, height));
|
||||
}
|
||||
bitmap.Render(drawing);
|
||||
@@ -340,6 +444,33 @@ public static class DialogShots
|
||||
new SheetSummary("Z001", "분류 없는 서식", false, true, string.Empty, "(미분류)"),
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 표본 — 환자 없이 만든다. 그러면 치환이 전부 빈 값이 되어 SQL 이 깨지고,
|
||||
/// 진단이 <b>실패 화면</b>을 찍는다. 그게 사람이 실제로 오래 보게 되는 화면이다.
|
||||
/// DB 접속 여부와 무관하게 같은 그림이 나온다(치환 단계에서 이미 실패한다).
|
||||
/// </summary>
|
||||
private static Services.MDataTableRunner SampleRunner()
|
||||
{
|
||||
var document = new SheetMe.Core.Models.FormDocument();
|
||||
var page = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 };
|
||||
document.Pages.Add(page);
|
||||
foreach (var (id, query) in new[]
|
||||
{
|
||||
("MDataTable1", "SELECT * FROM P_ComInf WHERE ComNum = <<bzPatientInfo.ComNum>>"),
|
||||
("MDataTable2", "SELECT 1 FROM DUAL"),
|
||||
})
|
||||
{
|
||||
var source = new SheetMe.Core.Models.ControlElement
|
||||
{
|
||||
Type = SheetMe.Core.Serialization.LegacyTypeCatalog.DataTableType, Id = id,
|
||||
Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 },
|
||||
};
|
||||
source.Props.SetText("Query", query);
|
||||
page.Controls.Add(source);
|
||||
}
|
||||
return new Services.MDataTableRunner(document, null);
|
||||
}
|
||||
|
||||
private static List<DesignVersionInfo> SampleVersions() => new()
|
||||
{
|
||||
new DesignVersionInfo(52443, "202608121030", "011825", false),
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,9 +36,20 @@ public static class MaximizeCheck
|
||||
private const uint AbmGetAutoHideBarEx = 0x0000000B;
|
||||
private const int AbsAutoHide = 0x00000001;
|
||||
|
||||
private const int ProbeMinWidth = 940;
|
||||
private const int ProbeMinHeight = 640;
|
||||
private const double ProbeBorder = 6;
|
||||
/// <summary>
|
||||
/// 검사에 쓰는 최소 크기·테두리는 <b>실제 MainView 에서 읽는다</b>.
|
||||
///
|
||||
/// 전에는 940 / 640 / 6 을 상수로 베껴 두었다. 그러면 MainView 의 최소 크기를 바꿨을 때
|
||||
/// 이 검사가 <b>옛 값을 계속 검사하며 계속 통과한다</b> — 게이트가 있는데 아무것도 지키지 않는 상태다.
|
||||
/// 창을 하나 만들어(띄우지 않는다) XAML 이 정한 값을 그대로 가져온다.
|
||||
/// </summary>
|
||||
private static (double MinWidth, double MinHeight, double Border) Real()
|
||||
{
|
||||
var main = new Views.MainView();
|
||||
var chrome = WindowChrome.GetWindowChrome(main);
|
||||
var border = chrome?.ResizeBorderThickness.Left ?? 6;
|
||||
return (main.MinWidth, main.MinHeight, border);
|
||||
}
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct RECT
|
||||
@@ -152,7 +163,9 @@ public static class MaximizeCheck
|
||||
var failures = 0;
|
||||
|
||||
// 프로브는 WindowStyle=None 이어야 한다 — 기본값이면 Attach 의 가드에 걸려 아무 일도 안 일어난다
|
||||
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
|
||||
var real = Real();
|
||||
report.AppendLine($"MainView 실측 — 최소 {real.MinWidth}x{real.MinHeight}, 리사이즈 테두리 {real.Border}");
|
||||
var probe = NewProbe(real.MinWidth, real.MinHeight);
|
||||
var handle = new WindowInteropHelper(probe).EnsureHandle();
|
||||
Services.MaximizeToWorkArea.Attach(probe);
|
||||
|
||||
@@ -226,8 +239,9 @@ public static class MaximizeCheck
|
||||
private static int Live(StringBuilder report, MonitorFacts monitor)
|
||||
{
|
||||
var failures = 0;
|
||||
var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(ProbeBorder) };
|
||||
var probe = NewProbe(ProbeMinWidth, ProbeMinHeight);
|
||||
var real = Real();
|
||||
var chrome = new WindowChrome { CaptionHeight = 40, ResizeBorderThickness = new Thickness(real.Border) };
|
||||
var probe = NewProbe(real.MinWidth, real.MinHeight);
|
||||
WindowChrome.SetWindowChrome(probe, chrome);
|
||||
probe.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
probe.Left = monitor.Bounds.Left + 40;
|
||||
@@ -263,7 +277,7 @@ public static class MaximizeCheck
|
||||
var handle = new WindowInteropHelper(probe).Handle;
|
||||
probe.MinHeight = workHeight + 200;
|
||||
var cramped = Ask(handle);
|
||||
probe.MinHeight = ProbeMinHeight;
|
||||
probe.MinHeight = real.MinHeight;
|
||||
probe.Close();
|
||||
|
||||
report.AppendLine($"좁은 화면 흉내 — 최소 높이 {workHeight + 200} 요구, 작업영역 높이 {workHeight}");
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Threading;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 모달 가림막 자동 검증 — <c>--modal-check</c>.
|
||||
///
|
||||
/// <b>무엇을 재는가.</b> 대화상자를 실제로 <c>ShowDialog()</c> 로 띄우고,
|
||||
/// 그 순간 화면에 가림막 창이 실제로 존재하는지 센다.
|
||||
///
|
||||
/// <b>왜 필요했나.</b> 처음 만든 판정이 틀렸다. <c>ShowDialog()</c> 는 창을 먼저 보이고
|
||||
/// (그때 Loaded 가 뜬다) <b>그다음에</b> 모달 루프에 들어가므로,
|
||||
/// Loaded 시점의 <c>ComponentDispatcher.IsThreadModal</c> 은 아직 false 다.
|
||||
/// 그래서 가림막이 한 번도 안 깔렸는데 코드는 멀쩡해 보였다 — 눈으로 열어 보기 전엔 모른다.
|
||||
/// </summary>
|
||||
public static class ModalCheck
|
||||
{
|
||||
#region Methods
|
||||
public static int Run()
|
||||
{
|
||||
var lines = new List<string>();
|
||||
var failed = 0;
|
||||
|
||||
void Check(string label, bool ok, string detail = "")
|
||||
{
|
||||
lines.Add($"{(ok ? "PASS" : "FAIL")} {label}{(detail.Length > 0 ? " — " + detail : string.Empty)}");
|
||||
if (!ok)
|
||||
{
|
||||
failed++;
|
||||
}
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Application.Current.ShutdownMode = ShutdownMode.OnExplicitShutdown;
|
||||
Services.ThemeManager.Apply(light: true);
|
||||
|
||||
// 실제 앱은 OnStartup 에서 이 배선을 건다. 진단 분기는 그보다 먼저 반환하므로
|
||||
// 검사에서 같은 배선을 직접 걸어야 프로덕션과 같은 것을 재게 된다.
|
||||
EventManager.RegisterClassHandler(typeof(Window), FrameworkElement.LoadedEvent,
|
||||
new RoutedEventHandler((sender, _) =>
|
||||
{
|
||||
if (sender is Window loaded)
|
||||
{
|
||||
Services.ModalScrim.Attach(loaded);
|
||||
}
|
||||
}));
|
||||
|
||||
// 뒤에 깔릴 창 — 실제 앱에서는 메인 셸이 이 자리다
|
||||
var owner = new Window
|
||||
{
|
||||
Title = "가림막 검사 — 뒤 창",
|
||||
Width = 600,
|
||||
Height = 400,
|
||||
Left = 80,
|
||||
Top = 80,
|
||||
ShowInTaskbar = false,
|
||||
};
|
||||
owner.Show();
|
||||
Application.Current.MainWindow = owner;
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = "가림막 검사 — 대화상자",
|
||||
Width = 300,
|
||||
Height = 200,
|
||||
Owner = owner,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
};
|
||||
// 자격을 밝힌 창만 어두워진다 — 아래 ⑤ 가 밝히지 않은 창을 따로 확인한다
|
||||
Services.ModalScrim.SetDimBehind(dialog, true);
|
||||
|
||||
var scrimsWhileOpen = -1;
|
||||
var dialogAbove = false;
|
||||
var modalAtLoaded = false;
|
||||
dialog.Loaded += (_, _) =>
|
||||
modalAtLoaded = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
|
||||
|
||||
// 대화상자가 떠 있는 동안 창 목록을 세고 닫는다.
|
||||
// BeginInvoke 로는 안 된다 — 이 호출이 Loaded 보다 먼저 큐에 들어가므로
|
||||
// 가림막을 거는 콜백보다 앞서 실행돼 항상 0 을 센다(실제로 그렇게 헛다리를 짚었다).
|
||||
var modalAtProbe = false;
|
||||
var timer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
|
||||
timer.Tick += (_, _) =>
|
||||
{
|
||||
timer.Stop();
|
||||
modalAtProbe = System.Windows.Interop.ComponentDispatcher.IsThreadModal;
|
||||
scrimsWhileOpen = CountScrims();
|
||||
dialogAbove = DialogIsAboveScrim(dialog);
|
||||
dialog.Close();
|
||||
};
|
||||
timer.Start();
|
||||
|
||||
dialog.ShowDialog();
|
||||
|
||||
lines.Add($"Loaded 시점 IsThreadModal = {modalAtLoaded}");
|
||||
lines.Add($"모달 루프 안 IsThreadModal = {modalAtProbe}");
|
||||
lines.Add($"대화상자가 떠 있는 동안의 가림막 수 = {scrimsWhileOpen}");
|
||||
|
||||
Check("모달일 때 가림막이 깔린다", scrimsWhileOpen == 1,
|
||||
$"실제 {scrimsWhileOpen}개");
|
||||
// 가림막이 대화상자 위에 오면 대화상자까지 같이 흐려진다 — 실제로 그렇게 만들었다
|
||||
Check("가림막이 대화상자 아래에 있다", dialogAbove);
|
||||
Check("닫으면 가림막이 걷힌다", CountScrims() == 0, $"실제 {CountScrims()}개");
|
||||
|
||||
// ⑤ 자격을 밝히지 않은 창은 어두워지지 않는다.
|
||||
//
|
||||
// 이것이 이 검사에서 가장 중요한 항목이다. 전에는 모든 ShowDialog 가 무조건 어두워져서
|
||||
// 태그 하나 고르는 데도 앱이 암전했다 — 하루 수백 번이면 신호가 아니라 소음이다.
|
||||
// 위 ①~④ 만 있으면 "깔린다"는 확인되지만 "안 깔려야 할 때 안 깔린다"는 확인되지 않는다.
|
||||
var plainScrims = -1;
|
||||
var plain = new Window
|
||||
{
|
||||
Title = "가림막 검사 — 자격 없는 대화상자",
|
||||
Width = 300, Height = 200, Owner = owner,
|
||||
ShowInTaskbar = false,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
};
|
||||
var plainTimer = new DispatcherTimer { Interval = TimeSpan.FromMilliseconds(400) };
|
||||
plainTimer.Tick += (_, _) =>
|
||||
{
|
||||
plainTimer.Stop();
|
||||
plainScrims = CountScrims();
|
||||
plain.Close();
|
||||
};
|
||||
plainTimer.Start();
|
||||
plain.ShowDialog();
|
||||
|
||||
lines.Add($"자격 없는 대화상자가 떠 있는 동안의 가림막 수 = {plainScrims}");
|
||||
Check("자격을 밝히지 않으면 어두워지지 않는다", plainScrims == 0, $"실제 {plainScrims}개");
|
||||
|
||||
owner.Close();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}");
|
||||
failed++;
|
||||
}
|
||||
|
||||
lines.Add($"결과: 실패 {failed}건");
|
||||
Console.Error.WriteLine(string.Join(Environment.NewLine, lines));
|
||||
return failed == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
private static extern IntPtr GetWindow(IntPtr hwnd, uint command);
|
||||
|
||||
/// <summary>
|
||||
/// 대화상자 바로 다음(= 아래) 창이 가림막인가.
|
||||
/// GW_HWNDNEXT = 2 는 z순서에서 이 창 다음에 오는 창을 준다.
|
||||
/// </summary>
|
||||
private static bool DialogIsAboveScrim(Window dialog)
|
||||
{
|
||||
var dialogHandle = new System.Windows.Interop.WindowInteropHelper(dialog).Handle;
|
||||
var next = GetWindow(dialogHandle, 2);
|
||||
foreach (Window window in Application.Current.Windows)
|
||||
{
|
||||
if (window.AllowsTransparency && !window.ShowInTaskbar
|
||||
&& new System.Windows.Interop.WindowInteropHelper(window).Handle == next)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>가림막은 투명 + 작업표시줄 미표시 + 소유자 있는 창이다</summary>
|
||||
private static int CountScrims()
|
||||
{
|
||||
var count = 0;
|
||||
foreach (Window window in Application.Current.Windows)
|
||||
{
|
||||
if (window.AllowsTransparency && !window.ShowInTaskbar && window.Owner is not null
|
||||
&& window.WindowStyle == WindowStyle.None)
|
||||
{
|
||||
count++;
|
||||
}
|
||||
}
|
||||
return count;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
using System.Globalization;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Media;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 레이아웃이 글자를 잘라먹었는지 <b>단정</b>한다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 지금 이 저장소에서는 글자가 잘렸다는 이유로 실패할 수 있는 것이 하나도 없다.
|
||||
/// 테스트 277건은 Core·Data 만 참조해 XAML 을 한 줄도 로드하지 않고,
|
||||
/// --dialog-shots 는 PNG 57장을 만들면서 단정하는 것은 창 배경색과 스타일 존재 여부 둘뿐이다.
|
||||
/// 즉 회귀를 잡는 유일한 수단이 <b>사람이 그림 57장을 보는 것</b>이었다.
|
||||
/// 실제로 이번 세션에만 "232 로는 이름이 잘린다", "220 이면 마지막 토글이 잘린다",
|
||||
/// "0 크기 Grid 가 자식을 폭 0 으로 재서 글자가 사라진다" 세 건이 눈으로만 잡혔다.
|
||||
///
|
||||
/// <b>왜 글자만 보는가.</b> WPF 에서 <c>DesiredSize > RenderSize</c> 는 정상인 경우가 많다
|
||||
/// (Auto 칸, 클리핑을 의도한 컨테이너, 측정 전 요소). 전부 훑으면 수백 건이 떠서 게이트가 무용지물이 된다.
|
||||
/// 잘려서 <b>실제로 손해가 나는 것</b>은 읽어야 할 글자뿐이라 거기만 본다.
|
||||
/// </summary>
|
||||
internal static class OverflowCheck
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>서브픽셀 반올림 오차 — 이보다 작은 초과는 잘린 것이 아니다</summary>
|
||||
private const double Slack = 0.75;
|
||||
#endregion
|
||||
|
||||
#region Types
|
||||
internal readonly record struct Violation(string Kind, string Text, string Detail)
|
||||
{
|
||||
public override string ToString() => $"{Kind}: \"{Text}\" — {Detail}";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 창의 시각 트리를 훑어 잘린 글자를 찾는다. <b>Measure/Arrange 가 끝난 뒤</b> 불러야 한다.
|
||||
///
|
||||
/// <paramref name="inspected"/> 를 함께 돌려주는 이유: 위반 0건은
|
||||
/// "깨끗하다"와 "훑기가 깨졌다"를 구분해 주지 않는다. 몇 개를 봤는지 같이 적어야
|
||||
/// 0 이 뜻을 갖는다(--cleartype 의 대조군과 같은 장치다).
|
||||
/// </summary>
|
||||
public static List<Violation> Inspect(Visual root, out int inspected)
|
||||
{
|
||||
var found = new List<Violation>();
|
||||
inspected = 0;
|
||||
Walk(root, found, ref inspected);
|
||||
return found;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// <b>대조군.</b> 일부러 잘린 글자 넷을 만들어 검사기가 실제로 잡는지 확인한다.
|
||||
///
|
||||
/// 이것이 없으면 위반 0건이 "화면이 깨끗하다"가 아니라 "검사기가 고장났다"일 수 있고,
|
||||
/// 그 둘은 보고서에서 똑같이 생겼다. 이번 세션에만 그 함정에 두 번 빠졌다
|
||||
/// (--maxrect 가 기본값 0 을 기대값 0 과 비교해 공허하게 통과했고,
|
||||
/// --modal-check 가 Loaded 보다 먼저 재서 언제나 0 을 셌다).
|
||||
/// </summary>
|
||||
public static List<string> SelfCheck()
|
||||
{
|
||||
var lines = new List<string> { "[넘침 검사기 자가 점검 — 일부러 잘린 것을 잡는가]" };
|
||||
|
||||
// 대조군은 <b>실제로 손해가 나는 모양</b>이어야 한다. 처음에는 "Width 를 좁게 주고 말줄임표를 끈"
|
||||
// 것을 잘림으로 놓았는데, 실측해 보니 그건 잘리는 게 아니라 자연폭 그대로 그려지는 것이었다
|
||||
// (ActualWidth 가 213.3 으로 나왔다). 대조군이 틀렸던 것이라 모양을 바꿨다.
|
||||
var zeroHost = new Grid { Width = 0, ClipToBounds = true };
|
||||
var zeroText = Text("0 크기 칸에 갇힌 글자", TextTrimming.CharacterEllipsis, tip: false);
|
||||
zeroHost.Children.Add(zeroText);
|
||||
|
||||
var spillHost = new Border { Width = 40, Height = 20, ClipToBounds = true };
|
||||
var spillText = Text("자르는 칸 밖으로 삐져나간 긴 문구", TextTrimming.None, tip: false);
|
||||
spillHost.Child = spillText;
|
||||
|
||||
var trimmedNoTip = Text("말줄임표는 있고 툴팁은 없는 긴 문구", TextTrimming.CharacterEllipsis, tip: false);
|
||||
trimmedNoTip.Width = 40;
|
||||
var trimmedWithTip = Text("툴팁이 있으니 전체를 읽을 수 있다", TextTrimming.CharacterEllipsis, tip: true);
|
||||
trimmedWithTip.Width = 40;
|
||||
|
||||
// 0 폭 칸에 갇힌 글자는 ActualWidth 가 정확히 0 이 되지 않는다 — 말줄임표 글리프 몫으로 9.6 이 남는다.
|
||||
// 그래서 이 대조군의 정답은 '크기0' 이 아니라 두 규칙이 함께 걸리는 것이다.
|
||||
// 이것도 처음에 기대값을 잘못 적었고 실측이 정정했다.
|
||||
// '크기0' 규칙 자체는 남긴다 — 0 크기 Grid 가 자식을 폭 0 으로 재던 실제 버그를 겨눈 것이고,
|
||||
// 합성으로 그 모양을 정확히 만들지 못한다는 사실은 규칙이 틀렸다는 뜻이 아니다.
|
||||
var cases = new (string Expect, TextBlock Element)[]
|
||||
{
|
||||
("컨테이너넘침,툴팁없이잘림", zeroText),
|
||||
("컨테이너넘침", spillText),
|
||||
("툴팁없이잘림", trimmedNoTip),
|
||||
("통과", trimmedWithTip),
|
||||
};
|
||||
|
||||
var host = new StackPanel();
|
||||
host.Children.Add(zeroHost);
|
||||
host.Children.Add(spillHost);
|
||||
host.Children.Add(trimmedNoTip);
|
||||
host.Children.Add(trimmedWithTip);
|
||||
var window = new Window
|
||||
{
|
||||
Width = 400, Height = 300, Left = -6000, Top = -6000,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
ShowActivated = false, ShowInTaskbar = false, Content = host,
|
||||
};
|
||||
window.Show();
|
||||
window.UpdateLayout();
|
||||
|
||||
foreach (var (expect, element) in cases)
|
||||
{
|
||||
var hits = Inspect(element, out var seen);
|
||||
var kinds = hits.Count == 0 ? "통과" : string.Join(",", hits.Select(h => h.Kind));
|
||||
var ok = kinds == expect && seen == 1;
|
||||
var block = element;
|
||||
lines.Add($"{(ok ? "PASS" : "FAIL")} 대조군 {expect,-14} → {kinds} (검사 {seen}개)"
|
||||
+ $" [보임={block.IsVisible} 폭={block.ActualWidth:0.#} 높이={block.ActualHeight:0.#}"
|
||||
+ $" 자연폭={(block.IsVisible ? NaturalWidth(block) : 0):0.#} 줄바꿈={block.TextWrapping}]");
|
||||
}
|
||||
window.Close();
|
||||
return lines;
|
||||
}
|
||||
|
||||
private static TextBlock Text(string text, TextTrimming trimming, bool tip)
|
||||
{
|
||||
var block = new TextBlock
|
||||
{
|
||||
Text = text,
|
||||
FontSize = 13,
|
||||
TextWrapping = TextWrapping.NoWrap,
|
||||
TextTrimming = trimming,
|
||||
HorizontalAlignment = HorizontalAlignment.Left,
|
||||
};
|
||||
if (tip)
|
||||
{
|
||||
block.ToolTip = text;
|
||||
}
|
||||
return block;
|
||||
}
|
||||
|
||||
private static void Walk(DependencyObject node, List<Violation> found, ref int inspected)
|
||||
{
|
||||
if (node is TextBlock text)
|
||||
{
|
||||
inspected++;
|
||||
Judge(text, found);
|
||||
}
|
||||
var count = VisualTreeHelper.GetChildrenCount(node);
|
||||
for (var i = 0; i < count; i++)
|
||||
{
|
||||
Walk(VisualTreeHelper.GetChild(node, i), found, ref inspected);
|
||||
}
|
||||
// 팝업 내용은 시각 트리에 안 걸리므로 논리 자식으로 따라 들어간다 —
|
||||
// 메뉴·플라이아웃·자동완성이 전부 이 경로에 있다
|
||||
if (node is FrameworkElement { } element)
|
||||
{
|
||||
foreach (var child in LogicalTreeHelper.GetChildren(element))
|
||||
{
|
||||
if (child is System.Windows.Controls.Primitives.Popup { Child: { } popupChild })
|
||||
{
|
||||
Walk(popupChild, found, ref inspected);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static void Judge(TextBlock text, List<Violation> found)
|
||||
{
|
||||
var content = text.Text;
|
||||
if (!text.IsVisible || content.Length == 0 || IsWhiteSpace(content))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var label = content.Length > 28 ? content[..28] + "…" : content;
|
||||
var owner = OwnerOf(text);
|
||||
|
||||
// ① 보이는데 크기가 0 — 이번 세션에 실제로 낸 버그다(0 크기 Grid 가 자식을 폭 0 으로 잰다).
|
||||
// 이 경우 사용자는 글자가 <b>아예 없는</b> 것으로 본다.
|
||||
if (text.ActualWidth < 0.5 || text.ActualHeight < 0.5)
|
||||
{
|
||||
found.Add(new Violation("크기0", label,
|
||||
$"{owner} ActualWidth={text.ActualWidth:0.#} ActualHeight={text.ActualHeight:0.#}"));
|
||||
return;
|
||||
}
|
||||
|
||||
// ② 세로로 잘림 — 줄바꿈이 일어났는데 높이를 안 준 경우다.
|
||||
//
|
||||
// <b>Margin 을 빼야 한다.</b> DesiredSize 는 Margin 을 포함하고 RenderSize 는 포함하지 않는다.
|
||||
// 그래서 그냥 비교하면 세로 Margin 이 있는 TextBlock 이 예외 없이 걸린다 —
|
||||
// 처음에 그렇게 만들어 30건이 떴고, 전부 오탐이었다
|
||||
// (도구 상자 그룹 머리가 "필요 27 > 실제 14" 로 나왔는데 그 13 이 Margin 이었다).
|
||||
var needHeight = text.DesiredSize.Height - text.Margin.Top - text.Margin.Bottom;
|
||||
if (needHeight - text.RenderSize.Height > Slack)
|
||||
{
|
||||
found.Add(new Violation("세로잘림", label,
|
||||
$"{owner} 필요 {needHeight:0.#} > 실제 {text.RenderSize.Height:0.#}"));
|
||||
}
|
||||
|
||||
// ③ 자기를 자르는 조상 밖으로 삐져나갔는가.
|
||||
//
|
||||
// <b>왜 ActualWidth 로는 못 잡는가.</b> TextTrimming 이 None 이면 TextBlock 은 주어진 Width 를
|
||||
// 지키지 않고 자연폭을 그대로 ActualWidth 로 보고한다(실측: Width=40 인데 ActualWidth=213.3).
|
||||
// 즉 WPF 에서 말줄임표 없는 NoWrap 글자는 <b>잘리는 것이 아니라 옆을 침범한다</b>.
|
||||
// 그래서 "글자 자연폭 > 자기 ActualWidth" 는 이 경우 항상 거짓이고, 대신
|
||||
// <b>자르는 조상의 사각형</b>과 비교해야 한다.
|
||||
Clipped(text, label, owner, found);
|
||||
|
||||
// ④ 말줄임표가 실제로 걸렸는가 — 이때는 ActualWidth 가 좁혀지므로 자연폭과 비교할 수 있다.
|
||||
if (text.TextWrapping != TextWrapping.NoWrap || text.TextTrimming == TextTrimming.None)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var natural = NaturalWidth(text);
|
||||
var available = text.ActualWidth - text.Padding.Left - text.Padding.Right;
|
||||
if (natural - available <= Slack)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 말줄임표는 있지만 툴팁이 없으면 전체 문구를 볼 방법이 없다.
|
||||
if (text.ToolTip is null && ToolTipService.GetToolTip(text) is null && !AncestorHasToolTip(text))
|
||||
{
|
||||
found.Add(new Violation("툴팁없이잘림", label,
|
||||
$"{owner} 필요 {natural:0.#} > 가용 {available:0.#}"));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 글자의 사각형을 <b>자기를 자르는 가장 가까운 조상</b>의 좌표로 옮겨 비교한다.
|
||||
/// 자르는 조상이 없으면 삐져나가도 안 잘리므로(옆과 겹칠 뿐) 위반으로 세지 않는다 —
|
||||
/// 겹침은 다른 종류의 결함이고 이 검사기가 판정할 수 있는 것이 아니다.
|
||||
/// </summary>
|
||||
private static void Clipped(TextBlock text, string label, string owner, List<Violation> found)
|
||||
{
|
||||
var node = (DependencyObject)text;
|
||||
for (var depth = 0; depth < 24; depth++)
|
||||
{
|
||||
node = VisualTreeHelper.GetParent(node);
|
||||
if (node is not FrameworkElement { IsVisible: true } ancestor)
|
||||
{
|
||||
if (node is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// ScrollViewer 안은 스크롤로 볼 수 있으므로 잘린 것이 아니다
|
||||
if (ancestor is ScrollViewer or ScrollContentPresenter)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (!ancestor.ClipToBounds && ancestor.Clip is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var box = text.TransformToAncestor(ancestor)
|
||||
.TransformBounds(new Rect(0, 0, text.ActualWidth, text.ActualHeight));
|
||||
var overRight = box.Right - ancestor.ActualWidth;
|
||||
var overBottom = box.Bottom - ancestor.ActualHeight;
|
||||
if (overRight > Slack || overBottom > Slack || box.Left < -Slack || box.Top < -Slack)
|
||||
{
|
||||
found.Add(new Violation("컨테이너넘침", label,
|
||||
$"{owner} → {ancestor.GetType().Name}"
|
||||
+ $"({ancestor.ActualWidth:0.#}×{ancestor.ActualHeight:0.#}) 밖으로 "
|
||||
+ $"우 {Math.Max(0, overRight):0.#} 하 {Math.Max(0, overBottom):0.#}"));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>이 글자가 한 줄로 다 그려지려면 몇 픽셀이 필요한가</summary>
|
||||
private static double NaturalWidth(TextBlock text)
|
||||
{
|
||||
var typeface = new Typeface(text.FontFamily, text.FontStyle, text.FontWeight, text.FontStretch);
|
||||
// 요소와 같은 서식 모드로 재야 한다 — Ideal 과 Display 는 글립 진행이 달라
|
||||
// 같은 글자도 폭이 다르게 나온다. 이 값은 생성자 인자로만 줄 수 있다.
|
||||
var formatted = new FormattedText(
|
||||
text.Text,
|
||||
CultureInfo.CurrentUICulture,
|
||||
text.FlowDirection,
|
||||
typeface,
|
||||
text.FontSize,
|
||||
Brushes.Black,
|
||||
numberSubstitution: null,
|
||||
TextOptions.GetTextFormattingMode(text),
|
||||
VisualTreeHelper.GetDpi(text).PixelsPerDip);
|
||||
return formatted.WidthIncludingTrailingWhitespace;
|
||||
}
|
||||
|
||||
/// <summary>행 전체에 툴팁이 걸린 경우가 흔하다 — 그러면 글자에 없어도 읽을 수 있다</summary>
|
||||
private static bool AncestorHasToolTip(DependencyObject node)
|
||||
{
|
||||
for (var i = 0; i < 6 && node is not null; i++)
|
||||
{
|
||||
node = VisualTreeHelper.GetParent(node);
|
||||
if (node is FrameworkElement { } element
|
||||
&& (element.ToolTip is not null || ToolTipService.GetToolTip(element) is not null))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>어디서 잘렸는지 — 이름 있는 가장 가까운 조상을 짚어 준다</summary>
|
||||
private static string OwnerOf(DependencyObject node)
|
||||
{
|
||||
for (var i = 0; i < 10 && node is not null; i++)
|
||||
{
|
||||
if (node is FrameworkElement { Name.Length: > 0 } named)
|
||||
{
|
||||
return named.Name;
|
||||
}
|
||||
node = VisualTreeHelper.GetParent(node);
|
||||
}
|
||||
return "(이름없음)";
|
||||
}
|
||||
|
||||
private static bool IsWhiteSpace(string value)
|
||||
{
|
||||
foreach (var character in value)
|
||||
{
|
||||
if (!char.IsWhiteSpace(character))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
using System.Windows;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
|
||||
namespace SheetMe.Designer.Diagnostics;
|
||||
|
||||
/// <summary>
|
||||
/// 운영 규모에서 상호작용이 얼마나 걸리는지 잰다 — <c>--scale-budget [출력파일] [컨트롤수]</c>.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> --edit-smoke 는 컨트롤 <b>3개</b> 합성 문서에서 배선만 확인한다.
|
||||
/// 운영 최대 서식은 1,067개다. 그 규모에서 무엇이 느린지 지금 CI 에 보이는 숫자가 하나도 없어서,
|
||||
/// 재설계가 새 상호작용을 얹으면 <b>재설계가 드러낸 느림을 재설계 탓으로 뒤집어쓴다</b>.
|
||||
///
|
||||
/// <b>무엇을 겨누는가.</b> 월드 좌표 조회가 O(N) 이고 그것이 O(N) 루프 안에 있다:
|
||||
/// <c>PageOf</c> 가 <c>Pages.FirstOrDefault(p => p.Controls.Contains(root))</c> 이고
|
||||
/// <c>ParentWorldOffset</c> → <c>WorldBoundsOf</c> 가 전부 이걸 탄다.
|
||||
/// 그래서 호버 한 번, 드래그 한 프레임, 마퀴 전체 선택이 각각 몇 밀리초인지가 이 진단의 답이다.
|
||||
///
|
||||
/// <b>시간 예산은 넉넉하게 잡는다.</b> 이 숫자는 기계와 부하에 따라 흔들리므로,
|
||||
/// 좁게 잡으면 멀쩡한 커밋에서 거짓 실패가 난다. 목적은 "빠른지"가 아니라
|
||||
/// <b>"사람이 못 쓸 정도로 느려지는 회귀를 잡는 것"</b>이다.
|
||||
/// 그래서 예산은 체감 한계(호버·드래그는 프레임당 16ms, 한 번 하는 조작은 1초) 기준으로 둔다.
|
||||
///
|
||||
/// <b>재지 못하는 것.</b> 여기서 도는 것은 뷰모델 계층뿐이다 — 시각 트리가 없으므로
|
||||
/// WPF 의 Measure/Arrange, 바인딩 재평가, 렌더가 빠져 있다.
|
||||
/// 줌이 LayoutTransform 이라 눈금마다 서브트리 전체를 다시 재는 비용,
|
||||
/// Undo 뒤 시각 트리를 통째로 다시 세우는 비용은 이 숫자에 들어 있지 않다.
|
||||
/// 이 진단이 답하는 질문은 "상호작용 산술이 규모에서 터지는가"이고, 답은 아니다 —
|
||||
/// 실측으로 O(N)-in-O(N) 경고가 과장이었음이 드러났다(§커밋 메시지 참조).
|
||||
/// </summary>
|
||||
internal static class ScaleBudget
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>프레임당 이 이상이면 60fps 를 못 지킨다 — 드래그가 끊겨 보인다</summary>
|
||||
private const double FrameBudgetMs = 16;
|
||||
|
||||
/// <summary>한 번 하는 조작(마퀴 전체 선택, 되돌리기)이 이 이상이면 멈춘 것으로 보인다</summary>
|
||||
private const double GestureBudgetMs = 1000;
|
||||
|
||||
private const int HoverFrames = 100;
|
||||
private const int DragFrames = 60;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public static int Run(string? outputPath, int controlCount)
|
||||
{
|
||||
var report = new StringBuilder();
|
||||
var failures = 0;
|
||||
|
||||
report.AppendLine($"운영 규모 예산 — 컨트롤 {controlCount}개");
|
||||
report.AppendLine();
|
||||
|
||||
var designer = BuildPage(controlCount);
|
||||
var controls = designer.Pages[0].Controls;
|
||||
report.AppendLine($"최상위 {controls.Count}개 + 중첩 자식 {controls.Sum(c => c.Model.Children.Count)}개,"
|
||||
+ $" 페이지 {designer.Pages.Count}장");
|
||||
if (controls.Count != controlCount)
|
||||
{
|
||||
report.AppendLine($"★ 만들려던 수와 다르다 — 이 상태의 숫자는 비교 대상이 못 된다");
|
||||
failures++;
|
||||
}
|
||||
report.AppendLine();
|
||||
|
||||
var controller = new InteractionController(designer);
|
||||
var none = new PointerContext(false, false, false, 1);
|
||||
var ctrl = new PointerContext(true, false, false, 1);
|
||||
|
||||
// ① 호버 — 드래그가 아니어도 마우스만 움직이면 매 프레임 히트테스트가 돈다
|
||||
failures += Budget(report, "호버(선택 0)", HoverFrames, FrameBudgetMs, frame =>
|
||||
designer.Interaction.CursorAt(new Point(60 + (frame % 40), 60 + (frame % 40))));
|
||||
|
||||
// ② 드래그 1개 — 가장 흔한 조작
|
||||
designer.Selection.SetSingle(controls[0]);
|
||||
failures += Budget(report, "드래그(선택 1)", DragFrames, FrameBudgetMs, frame =>
|
||||
{
|
||||
if (frame == 0)
|
||||
{
|
||||
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
|
||||
}
|
||||
controller.PointerMove(new Point(controls[0].X + 6 + frame, controls[0].Y + 6 + frame), none);
|
||||
});
|
||||
controller.CancelDrag();
|
||||
|
||||
// ③ 드래그 다수 — 정렬·같은 크기·간격 균등이 겨냥하는 바로 그 상황이다
|
||||
var many = Math.Min(500, controls.Count);
|
||||
designer.Selection.Set(controls.Take(many).ToList());
|
||||
var anchor = controls[0];
|
||||
failures += Budget(report, $"드래그(선택 {many})", DragFrames, FrameBudgetMs, frame =>
|
||||
{
|
||||
if (frame == 0)
|
||||
{
|
||||
controller.PointerDown(new Point(anchor.X + 2, anchor.Y + 2), none);
|
||||
}
|
||||
controller.PointerMove(new Point(anchor.X + 6 + frame, anchor.Y + 6 + frame), none);
|
||||
});
|
||||
controller.CancelDrag();
|
||||
designer.Selection.Clear();
|
||||
|
||||
// ④ 마퀴로 전부 선택 — 한 제스처 안에서 O(N²) 가 터지는 자리
|
||||
failures += Budget(report, "마퀴 전체 선택", 1, GestureBudgetMs, _ =>
|
||||
{
|
||||
controller.PointerDown(new Point(-40, -40), ctrl);
|
||||
controller.PointerMove(new Point(4000, 40000), ctrl);
|
||||
controller.PointerUp(new Point(4000, 40000), ctrl);
|
||||
});
|
||||
report.AppendLine($" (마퀴로 잡힌 것 {designer.Selection.Items.Count}개)");
|
||||
|
||||
// ⑤ 되돌리기 — 문서 전체 딥클론 + VM 전체 재생성이라 앱에서 가장 비싼 키 한 방이다
|
||||
designer.Selection.SetSingle(controls[0]);
|
||||
controller.PointerDown(new Point(controls[0].X + 2, controls[0].Y + 2), none);
|
||||
controller.PointerMove(new Point(controls[0].X + 40, controls[0].Y + 40), none);
|
||||
controller.PointerUp(new Point(controls[0].X + 40, controls[0].Y + 40), none);
|
||||
failures += Budget(report, "되돌리기 1회", 1, GestureBudgetMs, _ => designer.Undo.Undo());
|
||||
|
||||
report.AppendLine();
|
||||
report.AppendLine($"결과: 실패 {failures}건");
|
||||
var text = report.ToString();
|
||||
Console.Error.Write(text);
|
||||
if (outputPath is { Length: > 0 })
|
||||
{
|
||||
System.IO.File.WriteAllText(outputPath, text);
|
||||
}
|
||||
return failures == 0 ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 프레임당 평균으로 판정한다 — 최악값은 GC 한 번에 튀어서 거짓 실패를 낸다.
|
||||
/// 최악값은 함께 적어 사람이 볼 수 있게만 남긴다.
|
||||
/// </summary>
|
||||
private static int Budget(StringBuilder report, string name, int frames, double budgetMs, Action<int> work)
|
||||
{
|
||||
// 첫 회는 JIT·지연 초기화가 섞여 본 측정과 성격이 다르다 — 버리는 대신 따로 적는다
|
||||
var warmUp = Stopwatch.StartNew();
|
||||
work(0);
|
||||
warmUp.Stop();
|
||||
|
||||
var worst = 0.0;
|
||||
var watch = Stopwatch.StartNew();
|
||||
for (var frame = 0; frame < frames; frame++)
|
||||
{
|
||||
var one = Stopwatch.StartNew();
|
||||
work(frame);
|
||||
one.Stop();
|
||||
worst = Math.Max(worst, one.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
watch.Stop();
|
||||
|
||||
var average = watch.Elapsed.TotalMilliseconds / Math.Max(1, frames);
|
||||
var ok = average <= budgetMs;
|
||||
report.AppendLine($"{(ok ? "PASS" : "FAIL")} {name,-18} 평균 {average,8:0.00}ms"
|
||||
+ $" (예산 {budgetMs}ms, 최악 {worst:0.00}ms, 첫회 {warmUp.Elapsed.TotalMilliseconds:0.00}ms, {frames}회)");
|
||||
return ok ? 0 : 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 운영 최대 규모의 페이지를 만든다. 컨테이너에 담긴 자식을 섞는다 —
|
||||
/// 중첩 자식이 28,009개(그 중 RadioButton 15,026개)라 그것이 실제 모양이고,
|
||||
/// 월드 좌표 조회가 부모를 타고 올라가는 비용도 거기서만 나온다.
|
||||
/// </summary>
|
||||
private static DesignerViewModel BuildPage(int count)
|
||||
{
|
||||
var document = new FormDocument();
|
||||
var page = new FormPage { Width = 720, Height = 856 };
|
||||
document.Pages.Add(page);
|
||||
|
||||
// <b>최상위를 정확히 count 개로 만든다.</b> O(N) 원시연산(PageOf 의 Controls.Contains,
|
||||
// ControlsIntersecting)이 보는 N 은 최상위 개수이므로, 자식을 최상위에서 빼면 N 이 절반이 되어
|
||||
// 최악을 재지 못한다(처음에 그렇게 만들어 534 를 재고 있었다).
|
||||
// 컨테이너 자식은 그 위에 더 얹는다 — 중첩 자식 28,009개가 실제 모양이고,
|
||||
// 월드 좌표가 부모를 타고 올라가는 비용은 거기서만 난다.
|
||||
var panel = new ControlElement { Type = "Panel", Id = "Panel1" };
|
||||
panel.Bounds = new LayoutRect { X = 20, Y = 20, W = 660, H = 400 };
|
||||
page.Controls.Add(panel);
|
||||
for (var i = 0; i < count / 2; i++)
|
||||
{
|
||||
panel.Children.Add(new ControlElement
|
||||
{
|
||||
Type = "RadioButton",
|
||||
Id = $"N{i:0000}",
|
||||
Bounds = new LayoutRect { X = 8 + (i % 40) * 16, Y = 8 + (i / 40 % 24) * 16, W = 60, H = 14 },
|
||||
});
|
||||
}
|
||||
|
||||
for (var i = 0; i < count - 1; i++)
|
||||
{
|
||||
page.Controls.Add(new ControlElement
|
||||
{
|
||||
Type = i % 3 == 0 ? "RadioButton" : i % 3 == 1 ? "Label" : "TextBox",
|
||||
Id = $"C{i:0000}",
|
||||
Bounds = new LayoutRect { X = 20 + (i % 40) * 17, Y = 440 + (i / 40 % 24) * 17, W = 60, H = 14 },
|
||||
});
|
||||
}
|
||||
return new DesignerViewModel(document);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -50,6 +50,15 @@ public static class ConfigLoader
|
||||
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
|
||||
config.GridSize = ReadInt(root["Designer:GridSize"], 4);
|
||||
config.SnapThreshold = ReadInt(root["Designer:SnapThreshold"], 6);
|
||||
// 싸인 이미지 공유 매핑 — 배열(Images:SignatureShareMap:0…) 또는 세미콜론 한 줄 둘 다 받는다.
|
||||
// 병원 배포는 ini·json 을 손으로 고치는 일이 많아 한 줄 형태가 실수를 줄인다.
|
||||
config.SignatureShareMap = root.GetSection("Images:SignatureShareMap").GetChildren()
|
||||
.Select(c => c.Value ?? string.Empty)
|
||||
.Concat((root["Images:SignatureShareMap"] ?? string.Empty)
|
||||
.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
.Where(v => v.Contains('=', StringComparison.Ordinal))
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.ToArray();
|
||||
return config;
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,12 @@ public sealed class InteractionController
|
||||
private Rect groupBounds0;
|
||||
private readonly Dictionary<ControlViewModel, Rect> origBounds = new();
|
||||
private bool undoCaptured;
|
||||
|
||||
/// <summary>
|
||||
/// 이번 드래그가 Ctrl+복사로 <b>사본을 만들었는가</b> — Esc 취소가 좌표만 되돌리면 안 되기 때문이다.
|
||||
/// 이것이 없으면 사본이 원본과 픽셀 단위로 겹쳐 살아남고, 화면은 취소된 것처럼 보인다.
|
||||
/// </summary>
|
||||
private bool duplicatedThisDrag;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
@@ -156,8 +162,29 @@ public sealed class InteractionController
|
||||
if (!downControl.IsSelected)
|
||||
{
|
||||
var mates = designer.GroupMatesOf(downControl);
|
||||
designer.Selection.Set(designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
// Shift 는 이 제스처에서도 '더하기'다 — 기존 선택과 합친다(업 토글과 같은 규칙).
|
||||
// Ctrl 은 '복사'라서 더하기가 아니다: 합집합으로 두면 아까 선택해 둔 컨트롤까지
|
||||
// 같이 복제돼, 하나 집어 끌었는데 화면 다른 곳에 사본이 여럿 생긴다.
|
||||
designer.Selection.Set(
|
||||
ctx.Ctrl ? mates : designer.Selection.Items.Union(mates).ToList(), downControl);
|
||||
}
|
||||
// Ctrl+드래그 = 복사(디자이너 관례). 원본은 자리에 남고 <b>복제본이 끌린다</b>.
|
||||
//
|
||||
// <b>왜 여기서 복제하나.</b> 누름 시점에 하면 Ctrl+클릭(선택 토글)만으로도 사본이 생긴다.
|
||||
// 드래그 문턱을 넘은 이 지점이 "복사할 뜻"이 확정되는 유일한 자리다.
|
||||
//
|
||||
// <b>왜 붙여넣기가 아닌가.</b> Paste 는 항상 최상위 +12,+12 에 놓는다 — 패널 안 라디오를
|
||||
// 그렇게 복사하면 사본이 패널 밖으로 나가 <b>배타 그룹이 깨진다</b>(레거시 라디오는
|
||||
// 직계 부모가 그룹이다). 제자리 복제는 부모를 유지하므로 같은 문항 안에 남는다.
|
||||
if (ctx.Ctrl && designer.DuplicateSelectionInPlace())
|
||||
{
|
||||
// 복제가 이미 스냅샷을 찍었다 — 이어지는 이동은 추가로 찍지 않는다.
|
||||
// 그러면 "복사 후 이동" 이 Undo 두 번이 되고, 한 번만 누른 사용자는
|
||||
// 원본 자리에 사본이 겹쳐 남은 상태를 보게 된다.
|
||||
undoCaptured = true;
|
||||
duplicatedThisDrag = true;
|
||||
}
|
||||
// 복제가 거절되면(표 등) 그냥 이동한다 — 선택은 원본에 남아 있다
|
||||
BeginMove();
|
||||
DoMove(world, ctx); // 전환 프레임부터 즉시 추종
|
||||
}
|
||||
@@ -206,20 +233,21 @@ public sealed class InteractionController
|
||||
designer.ReassignPagesAfterMove(origBounds.Keys.ToList());
|
||||
designer.Overlay.ClearGuides();
|
||||
designer.Snap.EndDrag();
|
||||
designer.Overlay.ClearDragBadge();
|
||||
break;
|
||||
|
||||
case Mode.Resize:
|
||||
designer.Overlay.ClearGuides();
|
||||
designer.Snap.EndDrag();
|
||||
designer.Overlay.ClearDragBadge();
|
||||
break;
|
||||
|
||||
case Mode.Marquee:
|
||||
var rect = RectFrom(downPoint, world);
|
||||
designer.Overlay.UpdateMarquee(null);
|
||||
var hits = designer.ControlsIntersecting(rect)
|
||||
.SelectMany(designer.GroupMatesOf)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
// 마퀴 전용 규칙 — 그룹은 넓히되 컨테이너 자식은 부모로 되돌리지 않는다.
|
||||
// GroupMatesOf 를 그대로 쓰면 패널 안쪽을 정확히 골라 놓고도 결과가 패널 하나가 된다.
|
||||
var hits = designer.MarqueeHits(rect);
|
||||
if (ctx.Ctrl || ctx.Shift)
|
||||
{
|
||||
var merged = designer.Selection.Items.Union(hits).ToList();
|
||||
@@ -235,6 +263,7 @@ public sealed class InteractionController
|
||||
mode = Mode.None;
|
||||
downControl = null;
|
||||
origBounds.Clear();
|
||||
duplicatedThisDrag = false; // 이 드래그는 끝났다 — 다음 Esc 가 남의 복제를 물면 안 된다
|
||||
designer.RefreshOverlay();
|
||||
}
|
||||
|
||||
@@ -246,6 +275,29 @@ public sealed class InteractionController
|
||||
{
|
||||
if (mode is Mode.Move or Mode.Resize)
|
||||
{
|
||||
// Ctrl+드래그 복사를 취소하는 경우 — 좌표를 되돌리는 것으로는 부족하다.
|
||||
// 사본은 이미 <b>문서에 들어가 있고</b>, 원래 자리로 되돌리면 원본과 픽셀 단위로 겹쳐
|
||||
// 화면은 취소된 것처럼 보인다. 그대로 저장하면 종이에 같은 글자가 두 번 인쇄되고
|
||||
// 증거는 "조금 굵어 보인다" 뿐이다. 그래서 복제 자체를 물린다.
|
||||
//
|
||||
// 순서가 중요하다: Undo 는 문서를 딥클론으로 갈아끼우므로 origBounds·downControl 이
|
||||
// 죽은 참조가 된다. 상태를 <b>먼저</b> 비우고 그다음에 되돌린다.
|
||||
if (duplicatedThisDrag)
|
||||
{
|
||||
designer.Overlay.ClearGuides();
|
||||
designer.Snap.EndDrag();
|
||||
designer.Overlay.ClearDragBadge();
|
||||
mode = Mode.None;
|
||||
origBounds.Clear();
|
||||
downControl = null;
|
||||
duplicatedThisDrag = false;
|
||||
undoCaptured = false;
|
||||
designer.Undo.Undo();
|
||||
// 취소는 "없던 일로" 다 — Ctrl+Y 로 사본이 되살아나면 취소가 아니다
|
||||
designer.Undo.DiscardRedo();
|
||||
designer.RefreshOverlay();
|
||||
return;
|
||||
}
|
||||
foreach (var (vm, rect) in origBounds)
|
||||
{
|
||||
var offset = designer.ParentWorldOffset(vm);
|
||||
@@ -276,8 +328,14 @@ public sealed class InteractionController
|
||||
return;
|
||||
}
|
||||
if (mode == Mode.None)
|
||||
{
|
||||
// 컨테이너 안에 들어가 있으면 먼저 한 겹 나간다 — Enter 로 들어가고 Esc 로 나오는 짝이다.
|
||||
// 나갈 곳이 없을 때만 선택을 푼다(전에는 항상 풀어서, 패널 자식을 고른 뒤 Esc 를 누르면
|
||||
// 부모로 돌아가는 대신 선택이 통째로 사라졌다).
|
||||
if (!designer.SelectParent())
|
||||
{
|
||||
designer.Selection.Clear();
|
||||
}
|
||||
return;
|
||||
}
|
||||
CancelDrag();
|
||||
@@ -387,6 +445,11 @@ public sealed class InteractionController
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
// 끄는 동안 지금 값을 보여 준다 — 숫자가 인스펙터에만 있으면 끌다 말고 오른쪽을 봐야 한다
|
||||
if (designer.SelectionWorldBounds() is { } movedBounds)
|
||||
{
|
||||
designer.Overlay.SetDragBadge(movedBounds, resizing: false);
|
||||
}
|
||||
}
|
||||
|
||||
private void DoResize(Point world, PointerContext ctx)
|
||||
@@ -453,6 +516,10 @@ public sealed class InteractionController
|
||||
}
|
||||
|
||||
designer.Selection.NotifyBoundsChanged();
|
||||
if (designer.SelectionWorldBounds() is { } sizedBounds)
|
||||
{
|
||||
designer.Overlay.SetDragBadge(sizedBounds, resizing: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>드래그 첫 실변경 시 1회 스냅샷 — 드래그 전체 = Undo 1스텝</summary>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Core.Models;
|
||||
using SheetMe.Core.Serialization;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>한 MDataTable 의 실행 결과 — 또는 왜 실행하지 못했는지</summary>
|
||||
/// <param name="Name">데이터소스 컨트롤 이름(배선이 이 이름으로 찾는다)</param>
|
||||
/// <param name="Sql">치환을 마친 SQL — 사람이 눈으로 확인해야 하는 그 문장</param>
|
||||
/// <param name="Columns">결과 컬럼</param>
|
||||
/// <param name="Rows">결과 행</param>
|
||||
/// <param name="Error">실패 사유. null 이면 성공</param>
|
||||
/// <param name="Unknown">치환하지 못한 변수들 — 값이 빈 이유가 여기 있을 수 있다</param>
|
||||
public sealed record MDataTableResult(
|
||||
string Name, string Sql,
|
||||
IReadOnlyList<string> Columns, IReadOnlyList<string[]> Rows,
|
||||
string? Error, IReadOnlyList<string> Unknown)
|
||||
{
|
||||
public bool Ok => Error is null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 미리보기에서 MDataTable 을 <b>실제로 돌린다</b> — 레거시 <c>ucLoadSheetBase</c> 가
|
||||
/// 서식을 로드할 때 하던 일이다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 서식의 값 상당수는 태그가 아니라 이 경로로 온다. 태그만 채워 보면
|
||||
/// "미리보기는 되는데 실제 서식은 반쯤 빈" 상태를 확인할 수 없다.
|
||||
///
|
||||
/// <b>실행은 읽기만.</b> <see cref="OracleQueryWorkbench.Trial"/> 을 그대로 쓴다 —
|
||||
/// SELECT/WITH 만 통과시키는 문이 이미 거기 있고, 이 접속은 운영에 붙을 수 있다.
|
||||
/// 문을 두 군데 두면 한쪽이 느슨해진다.
|
||||
///
|
||||
/// <b>치환 값이 SQL 에 원문 그대로 박힌다</b>(레거시가 그렇다 — 그래야 같은 SQL 이 된다).
|
||||
/// 따옴표가 섞인 값은 <see cref="QuerySubstitutionResult.QuotedValues"/> 로 드러내고
|
||||
/// 실행은 SELECT/WITH 문이 막는다.
|
||||
///
|
||||
/// <b>한 번만 돌린다.</b> 컨트롤마다 돌리면 배선 40개짜리 서식에 왕복 40회가 된다 —
|
||||
/// 데이터소스 단위로 캐시한다. 환자를 바꾸면 러너를 새로 만든다(같은 러너를 재사용하면
|
||||
/// 옛 환자의 표가 남는다).
|
||||
/// </summary>
|
||||
public sealed class MDataTableRunner : IDataFieldResolver
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly IQueryVariableSource variables;
|
||||
private readonly OracleQueryWorkbench? workbench;
|
||||
private readonly Dictionary<string, MDataTableResult> cache = new(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>이름 → 그 데이터소스의 쿼리 원문</summary>
|
||||
private readonly Dictionary<string, string> queries = new(StringComparer.OrdinalIgnoreCase);
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
/// <param name="document">데이터소스와 그 쿼리를 여기서 모은다</param>
|
||||
/// <param name="variables">치환 값을 내주는 쪽 — 환자를 안 골랐으면 null</param>
|
||||
public MDataTableRunner(FormDocument document, IQueryVariableSource? variables)
|
||||
{
|
||||
this.variables = variables ?? EmptySource.Instance;
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
workbench = connection.Length > 0 ? new OracleQueryWorkbench(connection) : null;
|
||||
|
||||
foreach (var page in document.Pages)
|
||||
{
|
||||
Collect(page.Controls);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>이름이 붙은 데이터소스들 — 화면이 결과를 목록으로 보여 줄 때 쓴다</summary>
|
||||
public IReadOnlyList<string> Names => queries.Keys.OrderBy(n => n, StringComparer.OrdinalIgnoreCase).ToList();
|
||||
|
||||
/// <summary>환자 문맥이 붙어 있는가 — 없으면 치환이 전부 빈 값이 되어 결과가 무의미하다</summary>
|
||||
public bool HasContext => !ReferenceEquals(variables, EmptySource.Instance);
|
||||
|
||||
/// <summary>이 데이터소스를 돌린 결과 — 처음 물을 때 한 번만 실행한다</summary>
|
||||
public MDataTableResult Run(string name)
|
||||
{
|
||||
if (cache.TryGetValue(name, out var cached))
|
||||
{
|
||||
return cached;
|
||||
}
|
||||
var result = Execute(name);
|
||||
cache[name] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>모두 돌린다 — 미리보기가 결과를 한꺼번에 보여 줄 때</summary>
|
||||
public IReadOnlyList<MDataTableResult> RunAll() => Names.Select(Run).ToList();
|
||||
|
||||
public TagValue Resolve(DataTableFieldSpec spec)
|
||||
{
|
||||
if (!queries.ContainsKey(spec.TableName))
|
||||
{
|
||||
// 배선이 없는 데이터소스를 가리킨다 — 레거시 런타임은 빈 catch 로 삼켜서
|
||||
// 아무 표시 없이 빈칸이 된다. 여기서는 이름을 말한다.
|
||||
return new TagValue(false, $"데이터소스 '{spec.TableName}' 이 이 서식에 없습니다");
|
||||
}
|
||||
var run = Run(spec.TableName);
|
||||
if (!run.Ok)
|
||||
{
|
||||
return new TagValue(false, run.Error ?? "조회에 실패했습니다");
|
||||
}
|
||||
// 행 선택(Rows 형 번호 / Select 형 .NET DataTable.Select 필터)은
|
||||
// 순수 함수에 있다 — DB 없이 검증할 수 있어야 진단이 접속에 안 묶인다
|
||||
return DataTableFieldPicker.Pick(run.Columns, run.Rows, spec);
|
||||
}
|
||||
|
||||
private MDataTableResult Execute(string name)
|
||||
{
|
||||
var sqlRaw = queries.TryGetValue(name, out var q) ? q : string.Empty;
|
||||
if (sqlRaw.Trim().Length == 0)
|
||||
{
|
||||
return new MDataTableResult(name, string.Empty, Array.Empty<string>(),
|
||||
Array.Empty<string[]>(), "쿼리가 비어 있습니다", Array.Empty<string>());
|
||||
}
|
||||
var converted = QuerySubstitution.Apply(sqlRaw, variables);
|
||||
if (converted.HasLeftover)
|
||||
{
|
||||
// 접두어가 클래스 전체 이름이 아니면 토큰이 SQL 에 남는다 — 실행하면 ORA 구문오류다.
|
||||
// 미리 말해 준다. 오라클 오류 문구보다 이쪽이 고칠 곳을 알려 준다.
|
||||
return new MDataTableResult(name, converted.Sql, Array.Empty<string>(), Array.Empty<string[]>(),
|
||||
"치환되지 않은 변수가 남아 있습니다(접두어가 클래스 전체 이름이어야 합니다): "
|
||||
+ string.Join(", ", converted.Hits
|
||||
.Where(h => h.Kind == QuerySubstitutionKind.NotAVariable)
|
||||
.Select(h => h.Token).Distinct()),
|
||||
converted.UnknownTokens);
|
||||
}
|
||||
if (workbench is null)
|
||||
{
|
||||
return new MDataTableResult(name, converted.Sql, Array.Empty<string>(),
|
||||
Array.Empty<string[]>(), "DB 에 접속되어 있지 않습니다", converted.UnknownTokens);
|
||||
}
|
||||
|
||||
var trial = workbench.Trial(converted.Sql);
|
||||
return new MDataTableResult(name, converted.Sql, trial.Columns, trial.Rows,
|
||||
trial.Ok ? null : trial.Error ?? "조회에 실패했습니다", converted.UnknownTokens);
|
||||
}
|
||||
|
||||
/// <summary>데이터소스를 재귀로 모은다 — 패널·그룹박스 안에 있는 것도 배선 대상이다</summary>
|
||||
private void Collect(IEnumerable<ControlElement> controls)
|
||||
{
|
||||
foreach (var control in controls)
|
||||
{
|
||||
// <b>중립 타입명으로 비교한다.</b> 여기가 레거시 클래스명("MDataTable")을 보고 있어서
|
||||
// 실사용 서식에서는 데이터소스를 <b>한 개도 못 모았다</b> — XML 에서 읽는 순간
|
||||
// LegacyTypeCatalog 가 이미 "DataTable" 로 바꿔 두기 때문이다.
|
||||
// 그 결과 모든 DataTableField 배선이 "데이터소스 X 이 이 서식에 없습니다"로 실패해
|
||||
// 미리보기·인쇄에서 빈칸이 됐고, 미리보기의 '데이터소스...' 버튼은 영영 회색이었다.
|
||||
// 진단들이 ControlElement 를 손으로 만들며 같은 레거시 이름을 써서 초록으로 통과하고 있었다.
|
||||
if (string.Equals(control.Type, LegacyTypeCatalog.DataTableType, StringComparison.Ordinal))
|
||||
{
|
||||
queries[control.Id] = control.Props.GetText("Query") ?? string.Empty;
|
||||
}
|
||||
if (control.Children.Count > 0)
|
||||
{
|
||||
Collect(control.Children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>환자를 안 골랐을 때 — 모든 변수를 "모른다"로 답한다</summary>
|
||||
private sealed class EmptySource : IQueryVariableSource
|
||||
{
|
||||
public static readonly EmptySource Instance = new();
|
||||
|
||||
public string? Scalar(string className, string property) => null;
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Row(string className, string property) => null;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Media;
|
||||
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 모달 대화상자 뒤를 어둡게 덮는다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> WPF 의 <c>ShowDialog()</c> 는 입력만 막고 화면은 그대로 둔다.
|
||||
/// 대화상자가 앱과 같은 밝기로 떠 있으면 "이게 떠 있는 동안은 뒤를 못 만진다"가 안 읽혀서,
|
||||
/// 뒤를 클릭했다가 아무 반응이 없으면 멈춘 것으로 오해한다.
|
||||
///
|
||||
/// <b>왜 호출부마다 안 고쳤나.</b> ShowDialog 호출부가 17곳이고 앞으로 더 는다.
|
||||
/// 한 곳이라도 빠지면 그 대화상자만 다르게 동작하는데, 그건 눈으로 전수 확인해야만 알 수 있다.
|
||||
/// 창이 뜨는 순간을 전역으로 잡아 <b>모달일 때만</b> 건다(WindowChromeTheme 이 제목 표시줄 색을
|
||||
/// 같은 방식으로 거는 것과 같은 자리다).
|
||||
///
|
||||
/// 진단 모드는 창을 모달이 아니라 화면 밖에 그냥 띄우므로 이 경로를 타지 않는다 —
|
||||
/// --dialog-shots 산출물은 영향받지 않는다.
|
||||
/// </summary>
|
||||
public static class ModalScrim
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>지금 떠 있는 가림막 — 대화상자가 겹쳐 뜨면 그만큼 쌓인다</summary>
|
||||
private static readonly Stack<Window> Layers = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 창이 모달이면 뒤에 가림막을 깐다 — 창이 <b>보이기 시작할 때</b> 불러야 한다.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 이 창이 뒤를 어둡게 할 자격이 있는가 — <b>기본값은 아니다</b>.
|
||||
///
|
||||
/// 전에는 모든 <c>ShowDialog</c> 에 무조건 붙었다. 그래서 태그 하나 고르는 데도 앱이 암전했고,
|
||||
/// 하루 수백 번 그러면 그건 신호가 아니라 소음이다.
|
||||
///
|
||||
/// 암전이 옳은 자리는 <b>지금 멈추고 결정해야 하는 곳</b>뿐이다 —
|
||||
/// 되돌릴 수 없는 쓰기의 확인(운영 EMR 테이블에 버전을 만든다), 진행이 정의되지 않는 차단 통보.
|
||||
/// 값 하나를 고르는 일은 Undo 로 되돌아가고 판단 근거가 뒤 화면에 있으므로 가리면 안 된다.
|
||||
///
|
||||
/// 전역 후크는 그대로 둔다 — 호출부 17곳 중 하나만 빠져도 그 대화상자만 다르게 동작하고,
|
||||
/// 그건 눈으로 전수 확인해야만 알 수 있다. 대신 <b>기본을 뒤집었다</b>:
|
||||
/// 후크는 항상 돌지만, 창이 스스로 자격을 밝혀야 깔린다.
|
||||
/// 새 대화상자가 이것을 잊으면 암전이 안 되는데, 이제 그쪽이 안전한 기본값이다.
|
||||
/// </summary>
|
||||
public static readonly DependencyProperty DimBehindProperty =
|
||||
DependencyProperty.RegisterAttached("DimBehind", typeof(bool), typeof(ModalScrim),
|
||||
new PropertyMetadata(false));
|
||||
|
||||
public static void SetDimBehind(DependencyObject target, bool value)
|
||||
=> target.SetValue(DimBehindProperty, value);
|
||||
|
||||
public static bool GetDimBehind(DependencyObject target)
|
||||
=> (bool)target.GetValue(DimBehindProperty);
|
||||
|
||||
public static void Attach(Window window)
|
||||
{
|
||||
// 모달 판정을 여기서 바로 하면 안 된다. ShowDialog() 는 창을 먼저 보이고(그때 Loaded 가 온다)
|
||||
// <b>그다음에</b> 모달 루프에 들어가므로, 이 시점의 IsThreadModal 은 아직 false 다.
|
||||
// 실제로 그렇게 만들어서 가림막이 한 번도 안 깔렸다(--modal-check 가 그 상태를 잡았다).
|
||||
// 모달 프레임이 돌기 시작한 뒤에 다시 본다 — 한 프레임 늦게 깔리는 것은 눈에 띄지 않는다.
|
||||
window.Dispatcher.BeginInvoke(new Action(() => AttachIfModal(window)),
|
||||
System.Windows.Threading.DispatcherPriority.Background);
|
||||
}
|
||||
|
||||
private static void AttachIfModal(Window window)
|
||||
{
|
||||
// 모달이 아닌 창(메인 셸, 진단 렌더용 오프스크린 창)은 대상이 아니다.
|
||||
// 그리고 스스로 자격을 밝힌 창만 깐다 — 값을 고르는 대화상자는 뒤를 가리면 안 된다.
|
||||
if (!System.Windows.Interop.ComponentDispatcher.IsThreadModal || !window.IsVisible
|
||||
|| !GetDimBehind(window))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var behind = window.Owner ?? Application.Current?.MainWindow;
|
||||
if (behind is null || ReferenceEquals(behind, window) || !behind.IsVisible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var scrim = new Window
|
||||
{
|
||||
WindowStyle = WindowStyle.None,
|
||||
ResizeMode = ResizeMode.NoResize,
|
||||
AllowsTransparency = true,
|
||||
Background = new SolidColorBrush(Color.FromArgb(0x66, 0, 0, 0)),
|
||||
ShowInTaskbar = false,
|
||||
ShowActivated = false,
|
||||
IsHitTestVisible = false,
|
||||
Focusable = false,
|
||||
WindowStartupLocation = WindowStartupLocation.Manual,
|
||||
Owner = behind,
|
||||
};
|
||||
Cover(scrim, behind);
|
||||
// 뒤 창이 움직이거나 크기가 바뀌면 가림막도 따라가야 한다 — 안 그러면 어두운 사각형만 남는다
|
||||
void Follow(object? sender, EventArgs e) => Cover(scrim, behind);
|
||||
behind.LocationChanged += Follow;
|
||||
behind.SizeChanged += Follow;
|
||||
behind.StateChanged += Follow;
|
||||
|
||||
scrim.Show();
|
||||
// 가림막을 대화상자 <b>바로 아래</b>로 내린다.
|
||||
//
|
||||
// Activate() 로는 안 된다. 둘 다 같은 창을 소유자로 갖는 형제라 나중에 띄운 쪽이 위에 오고,
|
||||
// 활성 창을 바꾸는 것과 z순서를 바꾸는 것은 다른 일이다 —
|
||||
// 실제로 대화상자까지 같이 흐려졌다.
|
||||
PlaceBehind(scrim, window);
|
||||
Layers.Push(scrim);
|
||||
|
||||
window.Closed += (_, _) =>
|
||||
{
|
||||
behind.LocationChanged -= Follow;
|
||||
behind.SizeChanged -= Follow;
|
||||
behind.StateChanged -= Follow;
|
||||
if (Layers.Count > 0)
|
||||
{
|
||||
Layers.Pop();
|
||||
}
|
||||
scrim.Close();
|
||||
};
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll", CharSet = System.Runtime.InteropServices.CharSet.Unicode)]
|
||||
private static extern bool SetWindowPos(IntPtr hwnd, IntPtr insertAfter,
|
||||
int x, int y, int cx, int cy, uint flags);
|
||||
|
||||
/// <summary>
|
||||
/// <paramref name="scrim"/> 을 <paramref name="above"/> 바로 아래로 놓는다.
|
||||
/// SetWindowPos 의 insertAfter 는 "이 창 다음"이라는 뜻이라, 대화상자를 넘기면 그 아래가 된다.
|
||||
/// </summary>
|
||||
private static void PlaceBehind(Window scrim, Window above)
|
||||
{
|
||||
var scrimHandle = new System.Windows.Interop.WindowInteropHelper(scrim).Handle;
|
||||
var aboveHandle = new System.Windows.Interop.WindowInteropHelper(above).Handle;
|
||||
if (scrimHandle == IntPtr.Zero || aboveHandle == IntPtr.Zero)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// SWP_NOMOVE | SWP_NOSIZE | SWP_NOACTIVATE
|
||||
SetWindowPos(scrimHandle, aboveHandle, 0, 0, 0, 0, 0x0002 | 0x0001 | 0x0010);
|
||||
}
|
||||
|
||||
/// <summary>뒤 창을 정확히 덮는다 — 최대화 상태도 그 사각형 그대로다</summary>
|
||||
private static void Cover(Window scrim, Window behind)
|
||||
{
|
||||
if (behind.WindowState == WindowState.Minimized)
|
||||
{
|
||||
scrim.Visibility = Visibility.Collapsed;
|
||||
return;
|
||||
}
|
||||
scrim.Visibility = Visibility.Visible;
|
||||
|
||||
// 최대화된 창은 Left/Top 이 '복원했을 때의 값'이라 그대로 쓰면 가림막이 엉뚱한 데 깔린다.
|
||||
// 화면 좌표를 직접 물어 DIP 로 되돌린다.
|
||||
var origin = new Point(behind.Left, behind.Top);
|
||||
if (PresentationSource.FromVisual(behind) is { CompositionTarget: { } target })
|
||||
{
|
||||
origin = target.TransformFromDevice.Transform(behind.PointToScreen(new Point(0, 0)));
|
||||
}
|
||||
scrim.Left = origin.X;
|
||||
scrim.Top = origin.Y;
|
||||
scrim.Width = behind.ActualWidth > 0 ? behind.ActualWidth : behind.Width;
|
||||
scrim.Height = behind.ActualHeight > 0 ? behind.ActualHeight : behind.Height;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 보정을 마친 주민번호를 만드는 <b>유일한 자리</b>.
|
||||
///
|
||||
/// 왜 한 곳으로 좁히는가: 이 값은 이 앱이 다루는 것 중 가장 민감하다.
|
||||
/// 만드는 경로가 여러 곳이면 어디서 새는지 추적할 수 없고, 보정 순서가 어긋난 사본이 생긴다.
|
||||
///
|
||||
/// <b>값을 저장하지 않는다.</b> 필요할 때 만들어 <see cref="PatientTagResolver"/> 에 넘기고 끝낸다.
|
||||
/// 로그에도 남기지 않는다 — 어떤 진단 리포트에도 값이 아니라 <b>길이</b>만 적는다.
|
||||
///
|
||||
/// 복호화는 DB 가 한다(<see cref="PersonalDataStore"/>). 앱은 키를 갖지 않는다.
|
||||
/// 자세한 것은 <c>docs/RESNUM-DECRYPT.md</c>.
|
||||
/// </summary>
|
||||
public static class PatientIdentity
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 이 환자의 주민번호 — 못 만들면 빈 문자열.
|
||||
///
|
||||
/// <b>암호화 설정을 못 읽으면 만들지 않는다.</b> 설정을 "암호화 아님"으로 가정하면
|
||||
/// <c>PatResNum</c> 을 평문처럼 써서 암호문을 종이에 찍는다. 빈칸이 훨씬 낫다.
|
||||
/// </summary>
|
||||
public static string ResidentNumberOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return string.Empty;
|
||||
}
|
||||
var hospital = UserSession.Current.HspCod;
|
||||
try
|
||||
{
|
||||
var store = new PersonalDataStore(connection);
|
||||
var flags = store.Flags();
|
||||
var raw = flags.ResidentNumberEncrypted
|
||||
// 암호화 병원 — PatResNum 은 쓰지 않는다. PatResEnc 를 DB 가 풀어 준다
|
||||
? store.Decrypt(PatientContext.Value(context.PatInf, "PatResEnc"), hospital)
|
||||
: PatientContext.Value(context.PatInf, "PatResNum");
|
||||
|
||||
return ResidentNumber.Normalize(
|
||||
raw,
|
||||
PatientContext.Value(context.PatInf, "PatBthDay"),
|
||||
PatientContext.Value(context.PatInf, "PatNatCod"),
|
||||
hospital);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 사유는 남기지만 값은 남기지 않는다 — 예외 메시지에 SQL 이 섞여도 값은 바인드였다
|
||||
AppLog.Audit($"[주민번호] 만들지 못했습니다: {ex.GetType().Name}");
|
||||
return string.Empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 내원의 담당의 행 — 못 읽으면 빈 사전.
|
||||
///
|
||||
/// 어느 시각의 진료과를 볼지는 레거시 <c>OCM_담당의사</c> 가 세 갈래로 정한다
|
||||
/// (bzDataInterface.vb):
|
||||
/// · 입원이고 <c>ComPrgStt='ILV'</c>(퇴원) → 퇴원일시
|
||||
/// · 입원이고 그 밖 → 지금(= 문맥이 정한 적용일시)
|
||||
/// · 외래 → 접수일시
|
||||
/// 담당의 유효기간을 볼 날짜도 갈린다 — 입원은 진료과 행의 <c>CodEndDtm</c>, 외래는 접수일자.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> DoctorOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
var inpatient = PatientContext.Value(context.ComInf, "ComPatTyp") == "I";
|
||||
var accepted = PatientContext.Value(context.ComInf, "ComAcpDtm");
|
||||
var left = PatientContext.Value(context.ComInf, "ComLevDtm");
|
||||
var discharged = PatientContext.Value(context.ComInf, "ComPrgStt") == "ILV";
|
||||
|
||||
var moment = inpatient
|
||||
? (discharged && left.Length >= 12 ? left[..12] : context.AdpDtm)
|
||||
: (accepted.Length >= 12 ? accepted[..12] : context.AdpDtm);
|
||||
// 입원은 진료과 행의 종료일시로 담당의 유효기간을 본다 — 그 값은 저장소가 행을 읽은 뒤에야 안다.
|
||||
// 여기서는 외래만 날짜를 지정하고, 입원은 저장소가 적용일시에서 뽑게 둔다.
|
||||
var doctorDay = inpatient ? null : (accepted.Length >= 8 ? accepted[..8] : null);
|
||||
try
|
||||
{
|
||||
return new PatientContextStore(connection).Doctor(context.ComNum, moment, doctorDay);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주치의(전공의) 행 — 진료과 행의 <b>CodStfDtr</b> 로 찾는다. 시각 갈래는 담당의와 같다
|
||||
/// (bzDataInterface.vb:4026-4067). 코드가 비어 있으면 빈 사전 — 레거시도 즉시 "" 다.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> StaffDoctorOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
var inpatient = PatientContext.Value(context.ComInf, "ComPatTyp") == "I";
|
||||
var accepted = PatientContext.Value(context.ComInf, "ComAcpDtm");
|
||||
var left = PatientContext.Value(context.ComInf, "ComLevDtm");
|
||||
var discharged = PatientContext.Value(context.ComInf, "ComPrgStt") == "ILV";
|
||||
var moment = inpatient
|
||||
? (discharged && left.Length >= 12 ? left[..12] : context.AdpDtm)
|
||||
: (accepted.Length >= 12 ? accepted[..12] : context.AdpDtm);
|
||||
var doctorDay = inpatient ? null : (accepted.Length >= 8 ? accepted[..8] : null);
|
||||
try
|
||||
{
|
||||
var row = new PatientContextStore(connection)
|
||||
.Doctor(context.ComNum, moment, doctorDay, "CodStfDtr");
|
||||
// 코드가 비어 있으면 Doctor 가 진료과 행을 돌려준다 — 주치의 행이 아니므로 비운다
|
||||
return row.ContainsKey("UidNam") ? row : empty;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 상병 행 공급자 — 해석기가 <b>필요할 때</b> 갈래(전체/주/부)별로 부른다.
|
||||
/// 여기서 미리 읽지 않는 이유는 상병 태그가 없는 서식이 다수이기 때문이다.
|
||||
/// 실패는 던진다 — 해석기가 잡아 사유로 바꾼다(조용히 빈 목록을 주면
|
||||
/// "상병이 없다"와 "조회가 죽었다"를 화면에서 못 가른다).
|
||||
/// </summary>
|
||||
public static Func<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>>
|
||||
DiagnosisSourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return scope => connection.Length == 0
|
||||
? new List<Dictionary<string, string>>()
|
||||
: new DiagnosisStore(connection).Diagnoses(context.ComNum, scope);
|
||||
}
|
||||
|
||||
/// <summary>심사 상병(B_OkdInf) 공급자 — 상병과 같은 정책</summary>
|
||||
public static Func<DiagnosisStore.Scope, IReadOnlyList<Dictionary<string, string>>>
|
||||
ReviewDiagnosisSourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return scope => connection.Length == 0
|
||||
? new List<Dictionary<string, string>>()
|
||||
: new ReviewDiagnosisStore(connection).Diagnoses(context.ComNum, scope);
|
||||
}
|
||||
|
||||
/// <summary>바이탈 공급자 — 태그가 물을 때 한 값씩 읽는다(측정 태그 없는 서식이 다수다)</summary>
|
||||
public static Func<string, string, string[], string, string> VitalSourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return (expr, pick, notNull, order) => connection.Length == 0
|
||||
? string.Empty
|
||||
: new VitalStore(connection).One(context.ComNum, expr, pick, notNull, order);
|
||||
}
|
||||
|
||||
/// <summary>BST 공급자 — 표가 달라(E_EmdInf_BST) 바이탈과 분리</summary>
|
||||
public static Func<string, string> BstSourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return order => connection.Length == 0
|
||||
? string.Empty
|
||||
: new VitalStore(connection).OneBst(context.ComNum, order);
|
||||
}
|
||||
|
||||
/// <summary>차트 전체 기준 바이탈 공급자 — BMI·표준·조정체중이 쓴다</summary>
|
||||
public static Func<string, string, string[], string, string> ChartVitalSourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var chtNum = PatientContext.Value(context.ComInf, "ComChtNum");
|
||||
return (expr, pick, notNull, order) => connection.Length == 0 || chtNum.Length == 0
|
||||
? string.Empty
|
||||
: new VitalStore(connection).OneByChart(chtNum, expr, pick, notNull, order);
|
||||
}
|
||||
|
||||
/// <summary>병리판독의·진단검사의 공급자 — 환자와 무관하지만 환자 태그로 노출된다</summary>
|
||||
public static Func<IReadOnlyDictionary<string, string>> PathologyDoctorSource()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new PatientContextStore(connection).PathologyDoctor();
|
||||
}
|
||||
|
||||
public static Func<IReadOnlyDictionary<string, string>> LabDoctorSource()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new PatientContextStore(connection).LabDoctor();
|
||||
}
|
||||
|
||||
/// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 연결이 없으면 null(해석기가 사유로 말한다)</summary>
|
||||
public static Func<PatientExtraStore?> ExtraStoreSource()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0 ? null : new PatientExtraStore(connection);
|
||||
}
|
||||
|
||||
/// <summary>싸인·이미지 경로 조회 공급자 — 파일서버 전송은 옮기지 않았고 경로만 얻는다</summary>
|
||||
public static Func<SignatureStore?> SignatureStoreSource()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0 ? null : new SignatureStore(connection);
|
||||
}
|
||||
|
||||
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path)</summary>
|
||||
public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath();
|
||||
|
||||
/// <summary>파일서버 공유 매핑 — 설정(Images:SignatureShareMap)</summary>
|
||||
public static IReadOnlyList<string> ShareMap() => ConfigService.Current.SignatureShareMap;
|
||||
|
||||
/// <summary>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
|
||||
public static Func<PatientContextStore?> ContextStoreSource()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0 ? null : new PatientContextStore(connection);
|
||||
}
|
||||
|
||||
/// <summary>수술 일정(S_OprInf) 공급자 — 최신순 여부별 1회</summary>
|
||||
public static Func<bool, IReadOnlyDictionary<string, string>> ScheduledSurgerySourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return latest => connection.Length == 0
|
||||
? new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
|
||||
: new PatientContextStore(connection).ScheduledSurgery(context.ComNum, latest);
|
||||
}
|
||||
|
||||
/// <summary>수술 행 공급자 — 상병과 같은 정책(필요할 때 1회, 실패는 던져 사유로)</summary>
|
||||
public static Func<IReadOnlyList<Dictionary<string, string>>> SurgerySourceFor(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
return () => connection.Length == 0
|
||||
? new List<Dictionary<string, string>>()
|
||||
: new SurgeryStore(connection).Surgeries(context.ComNum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 내원의 진료과 마스터 행 — 못 읽으면 빈 사전.
|
||||
/// 진료과 코드는 문맥의 CodInf(적용일시로 고른 행)에서, 유효기간 일자는
|
||||
/// 레거시대로 <b>접수일자</b>다(bzDataInterface.vb:5445 — 입원·외래 무관).
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> DepartmentOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
return new PatientContextStore(connection).Department(
|
||||
PatientContext.Value(context.CodInf, "CodDepCod"),
|
||||
PatientContext.Value(context.ComInf, "ComAcpDtm"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 요양기관 행 — 못 읽으면 빈 사전.
|
||||
/// 판정 일자가 진료과와 <b>다르다</b>(bzDataInterface.vb:15815-15825):
|
||||
/// 입원·퇴원(ILV)은 퇴원일시, 입원 중은 지금, 외래는 접수일시 — 문맥의 적용일시가 그 판정과 같다.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> HospitalRowOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
return new PatientContextStore(connection).HospitalRow(
|
||||
PatientContext.Value(context.CodInf, "CodDepCod"), context.AdpDtm);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 환자의 주소 조각들(구역번호·도로명·지번·영문) — 못 읽으면 빈 사전.
|
||||
///
|
||||
/// 주민번호와 같은 이유로 <b>환자를 붙일 때 한 번만</b> 읽는다 —
|
||||
/// 주소 태그가 넷이라 태그마다 읽으면 왕복이 넷이 된다.
|
||||
/// 실패해도 던지지 않는다: 호출부가 상세주소만으로 값을 만들 수 있고, 레거시도 그렇게 한다.
|
||||
/// </summary>
|
||||
public static IReadOnlyDictionary<string, string> AddressOf(PatientContext context)
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
var empty = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (connection.Length == 0)
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
try
|
||||
{
|
||||
return new PatientContextStore(connection)
|
||||
.Address(PatientContext.Value(context.PatInf, "PatZipBdg"));
|
||||
}
|
||||
catch
|
||||
{
|
||||
return empty;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 환자 문맥을 MDataTable 쿼리의 치환 변수로 내준다 — 레거시가 <c>bzPatientInfo</c> 인스턴스를
|
||||
/// 넘기던 자리다.
|
||||
///
|
||||
/// <b>DataRow 접근형이 이 기능의 대부분을 실어 준다.</b>
|
||||
/// <c>PatInfDR.item("아무컬럼")</c> 은 <see cref="PatientContext"/> 의 다섯 사전을 그대로 조회하면
|
||||
/// 되고, 그 사전은 <c>SELECT *</c> 결과라 컬럼을 내가 미리 알 필요가 없다 —
|
||||
/// <b>짐작할 것이 없으니 조용히 틀릴 일도 없다.</b>
|
||||
///
|
||||
/// 스칼라 속성도 대부분 그 다섯 행의 컬럼 하나다. 그래서 표에 컬럼 이름만 적고
|
||||
/// <b>있으면 값, 없으면 모른다</b>고 답한다. 컬럼이 실제로 있는지는 DB 가 판정한다.
|
||||
///
|
||||
/// 산출값(Age·Sex 등 UDF 를 거치는 것)은 넣지 않았다 — 계산식을 짐작하면 값이 조용히 틀린다.
|
||||
/// 넣지 않은 것은 <see cref="QuerySubstitutionKind.Unknown"/> 으로 드러난다.
|
||||
/// </summary>
|
||||
public sealed class PatientQueryVariableSource : IQueryVariableSource
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>스칼라 속성 → (문맥 행, 컬럼). 컬럼 존재 여부는 DB 결과가 판정한다</summary>
|
||||
private static readonly Dictionary<string, (string Row, string Column)> ColumnScalars =
|
||||
new(StringComparer.Ordinal)
|
||||
{
|
||||
["ChtNum"] = ("ComInf", "ComChtNum"),
|
||||
["PatTyp"] = ("ComInf", "ComPatTyp"),
|
||||
["ComCvtCom"] = ("ComInf", "ComCvtCom"),
|
||||
["PatMblPhn"] = ("PatInf", "PatMblPhn"),
|
||||
["ComDayCar"] = ("ComInf", "ComDayCar"),
|
||||
};
|
||||
|
||||
private readonly PatientContext context;
|
||||
private readonly string uidCod;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PatientQueryVariableSource(PatientContext context, string uidCod)
|
||||
{
|
||||
this.context = context;
|
||||
this.uidCod = uidCod;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public string? Scalar(string className, string property)
|
||||
{
|
||||
if (className == QuerySubstitution.WorkClass)
|
||||
{
|
||||
// 작업 정보는 로그인 사용자와 적용일시뿐이다 — 레거시도 이 둘이 실사용의 전부다
|
||||
return property switch
|
||||
{
|
||||
"WrkUid" => uidCod,
|
||||
"AdpDtm" => context.AdpDtm,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
if (className != QuerySubstitution.PatientClass)
|
||||
{
|
||||
// 서식 정보(bzSheetInfo)는 아직 붙이지 않았다 — 모른다고 말한다
|
||||
return null;
|
||||
}
|
||||
// 계산해서 들고 있는 값이 먼저다. 이 둘은 컬럼이 아니라 판정 결과다
|
||||
// (적용일시는 VisitMoment 가 여섯 갈래로 정한다).
|
||||
if (property == "ComNum")
|
||||
{
|
||||
return context.ComNum.ToString("F0");
|
||||
}
|
||||
if (property is "AdpDtm" or "OrderAdpDtm")
|
||||
{
|
||||
return context.AdpDtm;
|
||||
}
|
||||
if (!ColumnScalars.TryGetValue(property, out var map))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var row = RowOf(map.Row);
|
||||
// 행 자체가 없으면(외래의 병실 등) 값이 빈 것이다 — 속성을 모르는 것과 다르다
|
||||
return row.TryGetValue(map.Column, out var value) ? value : row.Count == 0 ? string.Empty : null;
|
||||
}
|
||||
|
||||
public IReadOnlyDictionary<string, string>? Row(string className, string property)
|
||||
{
|
||||
if (className != QuerySubstitution.PatientClass)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
return property switch
|
||||
{
|
||||
"PatInfDR" => context.PatInf,
|
||||
"ComInfDR" => context.ComInf,
|
||||
"CodInfDR" => context.CodInf,
|
||||
"CoiInfDR" => context.CoiInf,
|
||||
"CowInfDR" => context.CowInf,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private IReadOnlyDictionary<string, string> RowOf(string name) => name switch
|
||||
{
|
||||
"PatInf" => context.PatInf,
|
||||
"ComInf" => context.ComInf,
|
||||
"CodInf" => context.CodInf,
|
||||
"CoiInf" => context.CoiInf,
|
||||
_ => context.CowInf,
|
||||
};
|
||||
#endregion
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 고른 환자 — 앱 전체가 하나를 공유한다.
|
||||
///
|
||||
/// <b>왜 전역인가.</b> 환자는 미리보기 창에서 고르는데, 그 값이 필요한 곳은 거기만이 아니다 —
|
||||
/// 쿼리 편집기의 '검증 실행'도 같은 환자로 돌려야 의미가 있다.
|
||||
/// 창마다 따로 들고 있으면 <b>같은 서식을 두 화면에서 서로 다른 환자로</b> 보게 되고,
|
||||
/// 그건 화면으로 구분할 수 없다.
|
||||
///
|
||||
/// <b>하나만 둔다.</b> 여러 개를 허용하면 어느 것이 지금 것인지 알 수 없고,
|
||||
/// 환자 정보가 걸린 문제에서 그건 개인정보 사고가 된다.
|
||||
///
|
||||
/// 앱이 닫힐 때까지 메모리에 남는다. 디스크에 쓰지 않는다 —
|
||||
/// 환자 문맥은 로그에도 안 남기기로 한 값이다(<see cref="PatientPreviewPolicy.AuditLine"/>).
|
||||
/// </summary>
|
||||
public static class PatientSession
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>환자가 바뀌었다 — 열려 있는 화면들이 다시 그려야 한다</summary>
|
||||
public static event EventHandler? Changed;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>지금 고른 환자의 문맥 — 안 골랐으면 null</summary>
|
||||
public static PatientContext? Current { get; private set; }
|
||||
|
||||
/// <summary>사람에게 보여 줄 한 줄 — 창 머리에 그대로 쓴다</summary>
|
||||
public static string Label { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>고른 환자가 있는가</summary>
|
||||
public static bool HasPatient => Current is not null;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>환자를 붙이거나(문맥 전달) 뗀다(null 전달)</summary>
|
||||
public static void Set(PatientContext? context, string label)
|
||||
{
|
||||
Current = context;
|
||||
Label = context is null ? string.Empty : label;
|
||||
Changed?.Invoke(null, EventArgs.Empty);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 치환 변수원 — 환자가 없으면 null 이고, 호출부는 그때 자리표시로 구문만 검사해야 한다.
|
||||
/// </summary>
|
||||
public static IQueryVariableSource? Variables()
|
||||
=> Current is { } context
|
||||
? new PatientQueryVariableSource(context, UserSession.Current.UidCod)
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// 이 문서용 태그·배선 해석기 한 벌 — 지금 세션 환자 기준.
|
||||
///
|
||||
/// 미리보기를 거치지 않는 인쇄(Ctrl+P)가 쓴다. 미리보기 창은 자기 해석기를 창 수명만큼
|
||||
/// 들고 있지만, 여기는 <b>한 번 뽑는 종이</b>라 그때그때 만든다.
|
||||
/// 주민번호·주소·담당의를 이 자리에서 한 번씩 읽는다 — 태그마다 읽으면 왕복이 늘어난다.
|
||||
/// </summary>
|
||||
public static (SheetMe.Core.Catalog.ITagValueResolver Tags, MDataTableRunner Fields)
|
||||
ResolversFor(SheetMe.Core.Models.FormDocument document)
|
||||
{
|
||||
var session = new SessionTagResolver();
|
||||
SheetMe.Core.Catalog.ITagValueResolver tags = Current is { } picked
|
||||
? new PatientTagResolver(picked, session,
|
||||
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
|
||||
PatientIdentity.DoctorOf(picked), PatientIdentity.StaffDoctorOf(picked),
|
||||
PatientIdentity.DepartmentOf(picked),
|
||||
PatientIdentity.HospitalRowOf(picked),
|
||||
PatientIdentity.DiagnosisSourceFor(picked),
|
||||
PatientIdentity.SurgerySourceFor(picked),
|
||||
PatientIdentity.ReviewDiagnosisSourceFor(picked),
|
||||
PatientIdentity.VitalSourceFor(picked),
|
||||
PatientIdentity.ScheduledSurgerySourceFor(picked),
|
||||
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
|
||||
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
|
||||
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
|
||||
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
|
||||
PatientIdentity.ShareMap())
|
||||
: session;
|
||||
return (tags, new MDataTableRunner(document, Variables()));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -16,20 +16,46 @@ public static class PrintService
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>인쇄 대화상자 → 전체 페이지 인쇄</summary>
|
||||
public static void Print(DesignerViewModel designer, string documentName)
|
||||
public static void Print(DesignerViewModel designer, string documentName,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var dialog = new System.Windows.Controls.PrintDialog();
|
||||
// 용지를 문서 크기로 미리 지정한다 — 지정하지 않으면 드라이버가 폭이 비슷한
|
||||
// 기본 용지에 얹는다. 실측: P062(720×856DIP = 540×642pt)를 PDF 로 뽑았더니
|
||||
// MediaBox 가 540×735pt(≈16절 190×260mm)였다 — 폭이 우연히 같아 좌우는 멀쩡하고
|
||||
// 아래에만 33mm 여백이 더 붙어 화면과 다른 종이가 됐다.
|
||||
// ShowDialog <b>전에</b> 넣어야 대화상자의 기본값이 되고, 사용자가 대화상자에서
|
||||
// 용지를 바꾸면 그 선택이 이긴다(우리는 기본값만 제시한다).
|
||||
if (designer.Pages.Count > 0)
|
||||
{
|
||||
var first = designer.Pages[0];
|
||||
dialog.PrintTicket.PageMediaSize = new System.Printing.PageMediaSize(
|
||||
first.WidthDip, first.HeightDip); // PageMediaSize 는 1/96" (DIP) 단위다
|
||||
}
|
||||
if (dialog.ShowDialog() != true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var document = BuildFixedDocument(designer);
|
||||
var document = BuildFixedDocument(designer, tags, fields, checks);
|
||||
dialog.PrintDocument(document.DocumentPaginator, $"SheetMe — {documentName}");
|
||||
}
|
||||
|
||||
/// <summary>페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용)</summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer)
|
||||
/// <summary>
|
||||
/// 페이지 VM 목록 → FixedDocument (미리보기/인쇄 공용).
|
||||
///
|
||||
/// <b>해석기를 반드시 받아 넘긴다.</b> 전에는 인자 없이 BuildPageVisual(page) 을 불러서
|
||||
/// 태그·데이터소스 해석기가 <b>인쇄 경로에서만 사라졌다</b> — 미리보기에는 환자 값이
|
||||
/// 찍히는데 그 창의 '인쇄' 버튼으로 뽑은 종이는 태그 이름 그대로였다.
|
||||
/// 화면에서 확인하고 인쇄하면 다른 종이가 나오는 것 — 미리보기의 존재 이유를 무너뜨리는 결함이었다.
|
||||
/// 실제 인쇄 PDF 와 미리보기 화면을 나란히 놓고서야 발견했다.
|
||||
/// </summary>
|
||||
public static FixedDocument BuildFixedDocument(DesignerViewModel designer,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var document = new FixedDocument();
|
||||
foreach (var page in designer.Pages)
|
||||
@@ -40,7 +66,9 @@ public static class PrintService
|
||||
Height = page.HeightDip,
|
||||
Background = page.PaperBrush,
|
||||
};
|
||||
fixedPage.Children.Add(BuildPageVisual(page));
|
||||
// 인쇄는 항상 '인쇄될 것만' 이다(레거시 인쇄가 PrintOutPut=False 를 뺀다) —
|
||||
// 미리보기의 보기 토글과 무관하게 printFilter 는 고정 true 다.
|
||||
fixedPage.Children.Add(BuildPageVisual(page, true, tags, fields, checks));
|
||||
|
||||
var pageContent = new PageContent();
|
||||
((System.Windows.Markup.IAddChild)pageContent).AddChild(fixedPage);
|
||||
@@ -49,8 +77,19 @@ public static class PrintService
|
||||
return document;
|
||||
}
|
||||
|
||||
/// <summary>페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용)</summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page)
|
||||
/// <summary>
|
||||
/// 페이지 컨트롤층 비주얼 — 크롬 없는 Canvas(그리기 순서 = 컬렉션 순서, 미리보기/인쇄 공용).
|
||||
///
|
||||
/// <paramref name="printFilter"/> 가 false 면 화면에 보이는 것을 그대로 그린다.
|
||||
/// <b>레거시가 그렇게 동작한다</b> — 레거시 미리보기는 <c>PrintOutPut=False</c> 컨트롤을
|
||||
/// 그대로 보여 주고(그 속성은 <c>PrintMe</c> 안에만 있다) 레거시 인쇄만 뺀다.
|
||||
/// 두 눈이 필요해서 미리보기 창이 보기를 전환할 수 있게 열어 둔다.
|
||||
/// 인쇄는 언제나 true 다 — 인쇄는 인쇄다.
|
||||
/// </summary>
|
||||
public static UIElement BuildPageVisual(PageViewModel page, bool printFilter = true,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags = null,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields = null,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
var canvas = new Canvas
|
||||
{
|
||||
@@ -66,14 +105,23 @@ public static class PrintService
|
||||
|
||||
foreach (var control in page.Controls)
|
||||
{
|
||||
// 숨김 + 데이터소스(MDataTable — 런타임 비가시)는 인쇄/미리보기에서 제외
|
||||
if (control.Model.Hidden || control is DataTableViewModel)
|
||||
// 인쇄/미리보기에서 빠지는 것 셋:
|
||||
// ① 디자이너 임시 숨김(Hidden — 저장되지 않는 편집 전용 상태)
|
||||
// ② 데이터소스(MDataTable — 런타임 비가시)
|
||||
// ③ 레거시 런타임이 인쇄에서 빼는 것: Visible=False 또는 PrintOutPut=False.
|
||||
// ③ 을 오래 빠뜨려서 '인쇄 출력'을 꺼도 미리보기에 그대로 나왔다 —
|
||||
// 저장 전 검증 수단이 거짓말을 하고 있었다.
|
||||
// 캔버스는 계속 다 보여 준다(편집 중인 것을 못 보면 고칠 수 없다) — 여기만 거른다.
|
||||
// Hidden(디자이너 임시 숨김)과 MDataTable(런타임 비가시)은 보기와 무관하게 항상 뺀다 —
|
||||
// 레거시 미리보기에서도 안 보이는 것들이다. 보기가 가르는 것은 ③ 뿐이다.
|
||||
if (control.Model.Hidden || control is DataTableViewModel
|
||||
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(control.Model)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var presenter = new ContentPresenter
|
||||
{
|
||||
Content = control,
|
||||
Content = PrintProjection(control, printFilter, tags, fields, checks),
|
||||
Width = Math.Max(1, control.Width),
|
||||
Height = Math.Max(1, control.Height),
|
||||
};
|
||||
@@ -83,5 +131,171 @@ public static class PrintService
|
||||
}
|
||||
return canvas;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 컨테이너를 <b>인쇄용으로 다시 만든 것</b>으로 바꿔 준다. 컨테이너가 아니면 원본 그대로.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 위 루프는 최상위만 훑는데 Panel·GroupBox 템플릿이 자식을 스스로 그린다
|
||||
/// (<c>ItemsSource="{Binding Children}"</c>). 그래서 여기서 아무리 걸러도 컨테이너 안은 그대로 나왔다 —
|
||||
/// Panel 안 컨트롤은 '인쇄 출력'을 꺼도 인쇄됐고, 컨테이너 안 MDataTable 은
|
||||
/// 파란 DB 배지가 종이에 찍혔다.
|
||||
///
|
||||
/// <b>왜 플래그가 아닌가.</b> 처음에는 컨테이너 VM 에 '인쇄 필터' 플래그를 켰다 끄는 방법을 썼는데
|
||||
/// 듣지 않았다 — WPF 는 템플릿 자식을 <b>레이아웃 시점</b>에 만드는데,
|
||||
/// 이 함수는 프레젠터만 만들어 두고 반환하므로 자식이 실체화될 때는 이미 플래그가 꺼져 있다.
|
||||
/// 플래그 수명을 인쇄 비주얼 전체로 늘리면 그동안 캔버스에서도 자식이 사라진다(VM 을 공유하므로).
|
||||
///
|
||||
/// 그래서 <b>같은 모델로 같은 타입의 VM 을 하나 더 만들고</b> 인쇄될 자식만 담는다.
|
||||
/// 타입이 같으니 템플릿이 그대로 잡히고, 모델이 같으니 배경·테두리·머리글이 전부 똑같이 나온다.
|
||||
/// 자식 VM 은 새로 만들지 않고 원본을 그대로 담는다 — 해석된 글꼴·색이 유지된다.
|
||||
/// 캔버스가 쓰는 VM 은 <b>한 글자도 건드리지 않는다.</b>
|
||||
/// </summary>
|
||||
private static object PrintProjection(ControlViewModel control, bool printFilter,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
if (control is not ContainerViewModel container)
|
||||
{
|
||||
return WithTagValue(control, tags, fields, checks);
|
||||
}
|
||||
ContainerViewModel? copy = container switch
|
||||
{
|
||||
PanelViewModel => new PanelViewModel(container.Model),
|
||||
GroupBoxViewModel => new GroupBoxViewModel(container.Model),
|
||||
// 모르는 컨테이너 타입은 손대지 않는다 — 잘못 투영하느니 원본을 그리는 쪽이 낫다
|
||||
_ => null,
|
||||
};
|
||||
if (copy is null)
|
||||
{
|
||||
return control;
|
||||
}
|
||||
foreach (var child in container.Children)
|
||||
{
|
||||
if (child.Model.Hidden || child is DataTableViewModel
|
||||
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(child.Model)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (PrintProjection(child, printFilter, tags, fields, checks) is ControlViewModel projected)
|
||||
{
|
||||
copy.Children.Add(projected);
|
||||
}
|
||||
}
|
||||
// 사본은 시각 컨텍스트를 아직 못 받았다 — 원본이 해석해 둔 값을 부모 값으로 넘긴다.
|
||||
// 컨테이너에 자기 글꼴 속성이 있으면 그것이 이기고, 없으면 상속값이 그대로 온다 — 양쪽 다 원본과 같다.
|
||||
copy.ResolveVisualContext(container.EffectiveFont, container.Foreground);
|
||||
return copy;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터 태그가 붙은 컨트롤을 <b>값이 들어간 사본</b>으로 바꾼다. 태그가 없거나 해석기가 없으면 원본.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 지금 미리보기는 태그를 글자 그대로 찍는다 —
|
||||
/// <c>PAT_이름</c> 이 종이에 그렇게 인쇄된다. 값이 흐를 관이 아예 없었다.
|
||||
///
|
||||
/// 컨테이너 투영과 같은 방식이다 — 모델을 복제해 Text 만 갈아 끼우고 같은 타입의 VM 을 새로 만든다.
|
||||
/// 타입이 같으니 템플릿이 그대로 잡히고, <b>캔버스가 쓰는 VM 과 모델은 건드리지 않는다.</b>
|
||||
///
|
||||
/// <b>못 만든 값은 빈칸이다</b>(2026-08-20 사용자 결정). 처음에는 사유를 대괄호로
|
||||
/// 감싸 그렸는데("못 만든 것을 감추지 않는다"), 실사용 출력물에 [태그 — 사유] 가
|
||||
/// 찍히면 안 되므로 값이 안 되는 태그는 종이·미리보기 모두 빈칸으로 바꾼다 —
|
||||
/// 태그 이름을 그대로 두면 값이 나온 줄 알기 때문에 이름도 지운다.
|
||||
/// 사유 자체는 사라지지 않는다 — 해석기(TagValue)가 계속 담고 있고,
|
||||
/// 진단(--db-patient·edit-smoke)이 그것으로 판정한다.
|
||||
/// </summary>
|
||||
private static object WithTagValue(ControlViewModel control, SheetMe.Core.Catalog.ITagValueResolver? tags,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields,
|
||||
SheetMe.Core.Catalog.PreviewCheckState? checks = null)
|
||||
{
|
||||
// 미리보기에서 눌러 본 체크·라디오는 <b>무엇보다 뒤다</b> — 태그가 만든 체크 상태까지 덮는다.
|
||||
// 그 칸이 어떻게 보이는지 직접 눌러 확인하는 중이므로 사용자의 손이 마지막이다.
|
||||
bool? clicked = null;
|
||||
if (control is CheckBoxViewModel or RadioButtonViewModel && checks is not null
|
||||
&& checks.Overrides.TryGetValue(control.Model.Id, out var state))
|
||||
{
|
||||
clicked = state;
|
||||
}
|
||||
// 두 관을 순서대로 본다. 태그가 먼저인 이유는 그것이 값을 만드는 더 단순한 경로이고,
|
||||
// 한 컨트롤에 둘이 다 걸려 있으면 어느 쪽이 이기는지 <b>정해져 있어야</b> 하기 때문이다
|
||||
// (정하지 않으면 같은 서식이 실행마다 다르게 보인다).
|
||||
var (label, value) = Bound(control, tags, fields);
|
||||
if (label is null)
|
||||
{
|
||||
return clicked is null ? control : Rechecked(control, clicked.Value);
|
||||
}
|
||||
// 체크류에서 값을 못 만들면 컨트롤을 그대로 둔다 — 레거시도 SetValue 를 건너뛰고
|
||||
// ($NoCheck$ · 오류) 라벨과 기본 체크 상태를 유지한다. 라벨에 사유를 쓰면
|
||||
// "남 ☑" 이 "[태그 — 사유] ☑" 가 되어 서식이 깨진다.
|
||||
if (!value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
|
||||
{
|
||||
return clicked is null ? control : Rechecked(control, clicked.Value);
|
||||
}
|
||||
// 이미지류(싸인·직인·로고)의 값은 <b>파일 경로</b>다 — Text 가 아니라 경로 속성에 넣어
|
||||
// 템플릿이 그 파일을 그리게 한다(레거시도 경로 문자열을 SetValue 로 넘겨 그렸다).
|
||||
// 못 찾았으면 아무것도 넣지 않는다 — 자리표시자만 남고 종이에는 글자가 없다.
|
||||
if (control is PictureBoxViewModel)
|
||||
{
|
||||
if (!value.Resolved)
|
||||
{
|
||||
return control;
|
||||
}
|
||||
var picture = control.Model.Clone();
|
||||
picture.Props.SetText("ResolvedImagePath", value.Text);
|
||||
return DocumentMapper.CreateControl(picture, control.EffectiveFont, control.Foreground);
|
||||
}
|
||||
var model = control.Model.Clone();
|
||||
if (value.Resolved && control is CheckBoxViewModel or RadioButtonViewModel)
|
||||
{
|
||||
// 체크류는 레거시 SetValue 의미론이다 — 값이 "Y" 또는 "TRUE"(대소문자 무시)면 체크,
|
||||
// 그 외는 언체크(CheckBox.vb:688-696 · RadioButton.vb:338-351). 라벨(Text)은 건드리지
|
||||
// 않는다 — 레거시도 체크 상태만 바꾼다. Boolean 태그(입통원구분 등)의 ToString
|
||||
// "True" 가 이 규칙으로 체크가 된다. 값을 Text 에 넣으면 라벨이 "True" 로 바뀐다.
|
||||
var upper = value.Text.ToUpperInvariant();
|
||||
model.Props.SetText("Checked", (clicked ?? upper is "Y" or "TRUE") ? "True" : "False");
|
||||
}
|
||||
else
|
||||
{
|
||||
// 값을 못 만들면 빈칸 — 사유를 종이에 쓰지 않는다(위 요약 참조)
|
||||
model.Props.SetText("Text", value.Resolved ? value.Text : string.Empty);
|
||||
}
|
||||
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
||||
}
|
||||
|
||||
/// <summary>미리보기에서 눌러 본 체크 상태만 갈아 끼운 사본 — 원본 VM·모델은 그대로다</summary>
|
||||
private static object Rechecked(ControlViewModel control, bool state)
|
||||
{
|
||||
var model = control.Model.Clone();
|
||||
model.Props.SetText("Checked", state ? "True" : "False");
|
||||
return DocumentMapper.CreateControl(model, control.EffectiveFont, control.Foreground);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 컨트롤에 걸린 배선과 그 값 — 배선이 없으면 라벨이 null 이다.
|
||||
///
|
||||
/// <c>DataTableField</c> 는 형식을 어기면 레거시 런타임이 <b>빈 catch 로 삼켜</b>
|
||||
/// 아무 표시 없이 빈칸이 된다(ucLoadSheetBase.DataTableBinding_). 여기서는 파싱 실패도 말한다 —
|
||||
/// 디자이너에서 정상으로 보이고 임상 화면에서만 빈칸이 되는 것이 이 배선의 고질적 고장이다.
|
||||
/// </summary>
|
||||
private static (string? Label, SheetMe.Core.Catalog.TagValue Value) Bound(
|
||||
ControlViewModel control,
|
||||
SheetMe.Core.Catalog.ITagValueResolver? tags,
|
||||
SheetMe.Core.Catalog.IDataFieldResolver? fields)
|
||||
{
|
||||
var tag = control.Model.Props.GetText("DataInterfaceTag");
|
||||
if (tags is not null && tag is { Length: > 0 } && tag != "None")
|
||||
{
|
||||
return (tag, tags.Resolve(tag));
|
||||
}
|
||||
var wiring = control.Model.Props.GetText("DataTableField");
|
||||
if (fields is null || wiring is not { Length: > 0 })
|
||||
{
|
||||
return (null, SheetMe.Core.Catalog.TagValue.None);
|
||||
}
|
||||
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(wiring);
|
||||
return spec is null
|
||||
? (wiring, new SheetMe.Core.Catalog.TagValue(false, "배선 형식을 읽을 수 없습니다"))
|
||||
: ($"{spec.TableName}.{spec.Field}", fields.Resolve(spec));
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 세션만으로 만들 수 있는 태그 값 — 서버 시각 계열 11종, 로그인 사용자 계열 5종.
|
||||
///
|
||||
/// <b>이것이 오늘 가능한 전부다.</b> 데이터 태그 384종 중 값이 나오는 것은 이 16종(운영 사용 579건, 9.1%)뿐이고
|
||||
/// 나머지는 환자·내원 문맥이 있어야 한다. 그 문맥이 생기면 이 클래스를 대체하는 것이 아니라
|
||||
/// <b>앞에 하나 더 붙인다</b> — 시각·사용자 태그는 그때도 여기서 나온다.
|
||||
///
|
||||
/// <b>DB 는 창당 한 번만 읽는다.</b> 재료는 서버 시각 1회 + 사용자 1행 1회로 끝난다
|
||||
/// (<see cref="TagPreviewStore"/>). 태그마다 읽으면 태그 40개짜리 서식에 왕복 40회가 된다 —
|
||||
/// 레거시가 그렇게 하고 있고, 그것을 그대로 옮기지 않기로 한 이유다.
|
||||
/// </summary>
|
||||
public sealed class SessionTagResolver : ITagValueResolver
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly TagPreviewStore? store;
|
||||
private TagPreviewContext? context;
|
||||
private bool tried;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public SessionTagResolver()
|
||||
{
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
store = connection.Length > 0 ? new TagPreviewStore(connection) : null;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public TagValue Resolve(string tag)
|
||||
{
|
||||
if (tag.Length == 0)
|
||||
{
|
||||
return TagValue.None;
|
||||
}
|
||||
if (!TagPreviewCatalog.CanResolve(tag))
|
||||
{
|
||||
// 사유를 돌려준다 — 빈칸으로 두면 사용자는 서식이 잘못된 줄 안다
|
||||
return new TagValue(false, TagPreviewCatalog.ReasonText(tag));
|
||||
}
|
||||
if (store is null || !store.CanUseDb)
|
||||
{
|
||||
return new TagValue(false, "DB 에 접속되어 있지 않습니다");
|
||||
}
|
||||
if (!tried)
|
||||
{
|
||||
tried = true;
|
||||
context = store.Read(UserSession.Current.UidCod, UserSession.Current.UidNam);
|
||||
}
|
||||
if (context is not { } ready)
|
||||
{
|
||||
return new TagValue(false, "값을 읽지 못했습니다");
|
||||
}
|
||||
var value = TagPreviewCatalog.KindOf(tag) == TagPreviewKind.Clock
|
||||
? TagPreviewCatalog.FromClock(tag, ready.Minute12)
|
||||
: TagPreviewCatalog.FromUser(tag, ready.UidCod, ready.UidNam,
|
||||
ready.MobilePhone, ready.OfficePhone,
|
||||
UserSession.Current.LicenseNumber, UserSession.Current.SpecialistNumber,
|
||||
UserSession.Current.JobName, UserSession.Current.DepNam,
|
||||
UserSession.Current.DepPhone);
|
||||
return value.Length > 0
|
||||
? new TagValue(true, value)
|
||||
: new TagValue(false, ready.HasClock ? "이 계정에는 값이 비어 있습니다" : "값을 읽지 못했습니다");
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -16,6 +16,14 @@ public static class StartupArguments
|
||||
{
|
||||
#region Methods
|
||||
/// <summary>진단 플래그 기동인지 — 첫 인자가 "--" 로 시작</summary>
|
||||
/// <summary>
|
||||
/// 이번 실행이 진단 모드인가. App 이 기동 때 한 번 정하고, 화면 쪽에서 읽는다.
|
||||
///
|
||||
/// 화면이 이것을 봐야 하는 이유: 게이트가 MainView 를 띄울 때 Loaded 커맨드가 DB 를 치면
|
||||
/// 검사 결과가 접속 상태에 따라 달라진다. 게이트는 DB 없이 같은 답을 내야 한다.
|
||||
/// </summary>
|
||||
public static bool DiagnosticMode { get; set; }
|
||||
|
||||
public static bool IsDiagnostic(string[] args)
|
||||
=> args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal);
|
||||
|
||||
|
||||
@@ -19,6 +19,9 @@ public sealed class UndoService
|
||||
|
||||
/// <summary>마지막 저장 시점의 undo 깊이 — -1 은 저장 지점이 소실되어 항상 더티</summary>
|
||||
private int savedDepth;
|
||||
|
||||
/// <summary>마지막으로 통지한 더티 값 — 바뀔 때만 알리기 위해 들고 있다</summary>
|
||||
private bool lastDirty;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
@@ -47,6 +50,12 @@ public sealed class UndoService
|
||||
#region Events
|
||||
/// <summary>복원 발생 통지(Undo/Redo 실행 후)</summary>
|
||||
public event Action? Restored;
|
||||
|
||||
/// <summary>
|
||||
/// <see cref="IsDirty"/> 값이 <b>바뀌었을 때만</b> 통지 — 탭의 저장 안 됨 표시가 이것으로 따라온다.
|
||||
/// 매번 통지하면 드래그 한 번에 수백 번 울린다.
|
||||
/// </summary>
|
||||
public event Action? DirtyChanged;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
@@ -72,10 +81,15 @@ public sealed class UndoService
|
||||
}
|
||||
redoStack.Clear();
|
||||
lastNudgeAt = DateTime.MinValue;
|
||||
RaiseDirtyIfChanged();
|
||||
}
|
||||
|
||||
/// <summary>저장 완료 시점 표시 — 이후 편집이 없으면 <see cref="IsDirty"/> 가 false</summary>
|
||||
public void MarkSaved() => savedDepth = undoStack.Count;
|
||||
public void MarkSaved()
|
||||
{
|
||||
savedDepth = undoStack.Count;
|
||||
RaiseDirtyIfChanged();
|
||||
}
|
||||
|
||||
/// <summary>넛지(방향키) 스냅샷 — 400ms 이내 연속 입력은 1스텝으로 코얼레스</summary>
|
||||
public void SnapshotForNudge()
|
||||
@@ -100,6 +114,27 @@ public sealed class UndoService
|
||||
undoStack.RemoveAt(undoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
RaiseDirtyIfChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 다시 실행 이력을 버린다 — <b>취소</b>한 조작이 Ctrl+Y 로 되살아나면 안 될 때.
|
||||
///
|
||||
/// Esc 로 드래그를 취소하는 경우가 그렇다. 사용자는 "되돌린다"가 아니라 "없던 일로 한다"고
|
||||
/// 생각하는데, 취소가 Undo 로 구현되어 있으면 그 조작이 redo 이력에 남아 되살아난다.
|
||||
/// </summary>
|
||||
public void DiscardRedo() => redoStack.Clear();
|
||||
|
||||
/// <summary>더티 값이 실제로 바뀐 경우에만 통지한다</summary>
|
||||
private void RaiseDirtyIfChanged()
|
||||
{
|
||||
var now = IsDirty;
|
||||
if (now == lastDirty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
lastDirty = now;
|
||||
DirtyChanged?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>다시 실행</summary>
|
||||
@@ -114,6 +149,7 @@ public sealed class UndoService
|
||||
redoStack.RemoveAt(redoStack.Count - 1);
|
||||
restoreDocument(snapshot);
|
||||
Restored?.Invoke();
|
||||
RaiseDirtyIfChanged();
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -16,7 +16,14 @@ public static class UserPrefs
|
||||
private static readonly string SettingsPath =
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "SheetMe", "prefs.json");
|
||||
|
||||
private static Dictionary<string, bool> values = new(StringComparer.Ordinal);
|
||||
/// <summary>
|
||||
/// 값 형식이 <see cref="JsonElement"/> 인 이유: 전에는 <c>Dictionary<string, bool></c> 였고
|
||||
/// 그래서 숫자를 담을 수 없었다(창 크기·패널 폭을 저장하지 못한 원인이다).
|
||||
/// 여기서 형식을 <c>string</c> 이나 <c>double</c> 로 바꾸면 이미 깔린 prefs.json 이
|
||||
/// 역직렬화에서 터져 <b>기존 취향이 통째로 초기화된다</b>.
|
||||
/// JsonElement 는 <c>false</c> 와 <c>640</c> 을 모두 그대로 받으므로 옛 파일이 살아남는다.
|
||||
/// </summary>
|
||||
private static Dictionary<string, JsonElement> values = new(StringComparer.Ordinal);
|
||||
private static bool loaded;
|
||||
#endregion
|
||||
|
||||
@@ -25,21 +32,58 @@ public static class UserPrefs
|
||||
public static bool GetBool(string key, bool fallback)
|
||||
{
|
||||
EnsureLoaded();
|
||||
return values.TryGetValue(key, out var value) ? value : fallback;
|
||||
return values.TryGetValue(key, out var value)
|
||||
&& value.ValueKind is JsonValueKind.True or JsonValueKind.False
|
||||
? value.GetBoolean()
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/// <summary>불리언 취향 저장</summary>
|
||||
public static void SetBool(string key, bool value)
|
||||
{
|
||||
EnsureLoaded();
|
||||
if (values.TryGetValue(key, out var current) && current == value)
|
||||
if (GetBool(key, !value) == value && values.ContainsKey(key))
|
||||
{
|
||||
return;
|
||||
}
|
||||
values[key] = value;
|
||||
values[key] = JsonSerializer.SerializeToElement(value);
|
||||
Save();
|
||||
}
|
||||
|
||||
/// <summary>숫자 취향 읽기 — 미저장이거나 형식이 다르면 기본값</summary>
|
||||
public static double GetDouble(string key, double fallback)
|
||||
{
|
||||
EnsureLoaded();
|
||||
return values.TryGetValue(key, out var value)
|
||||
&& value.ValueKind == JsonValueKind.Number
|
||||
&& value.TryGetDouble(out var number)
|
||||
&& double.IsFinite(number)
|
||||
? number
|
||||
: fallback;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 숫자 취향 여러 개를 한 번에 저장 — 창을 닫을 때 6개를 각각 저장하면 파일을 6번 쓴다.
|
||||
/// </summary>
|
||||
public static void SetDoubles(params (string Key, double Value)[] entries)
|
||||
{
|
||||
EnsureLoaded();
|
||||
var changed = false;
|
||||
foreach (var (key, value) in entries)
|
||||
{
|
||||
if (!double.IsFinite(value) || Math.Abs(GetDouble(key, double.NaN) - value) < 0.5)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
values[key] = JsonSerializer.SerializeToElement(Math.Round(value, 1));
|
||||
changed = true;
|
||||
}
|
||||
if (changed)
|
||||
{
|
||||
Save();
|
||||
}
|
||||
}
|
||||
|
||||
private static void EnsureLoaded()
|
||||
{
|
||||
if (loaded)
|
||||
@@ -51,8 +95,8 @@ public static class UserPrefs
|
||||
{
|
||||
if (File.Exists(SettingsPath))
|
||||
{
|
||||
values = JsonSerializer.Deserialize<Dictionary<string, bool>>(File.ReadAllText(SettingsPath))
|
||||
?? new Dictionary<string, bool>(StringComparer.Ordinal);
|
||||
values = JsonSerializer.Deserialize<Dictionary<string, JsonElement>>(File.ReadAllText(SettingsPath))
|
||||
?? new Dictionary<string, JsonElement>(StringComparer.Ordinal);
|
||||
}
|
||||
}
|
||||
catch
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
using System.Windows;
|
||||
|
||||
namespace SheetMe.Designer.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 창 크기·위치·패널 폭을 실행 사이에 기억한다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 전에는 아무것도 기억하지 않아서, 창을 옮기거나 패널 폭을 조절해도
|
||||
/// 다음 실행에 원래대로 돌아갔다. 하루 종일 쓰는 사람에게 그 조작은 매일 반복하는 일이 된다.
|
||||
///
|
||||
/// <b>복원값을 반드시 클램프한다.</b> 저장된 사각형이 지금 화면에 없을 수 있다 —
|
||||
/// 회사에서 두 번째 모니터를 쓰다 집에서 노트북만 켜면, 저장된 위치가 존재하지 않는 화면을 가리킨다.
|
||||
/// 이 앱은 <c>WindowStyle="None"</c> 이라 OS 캡션이 없고 제목 표시줄을 직접 그리므로,
|
||||
/// 창이 화면 위쪽으로 나가면 <b>메뉴·저장·닫기가 전부 손에 닿지 않는다</b>(마우스로 되돌릴 방법이 없다).
|
||||
/// 그래서 복원 전에 작업영역과 교차 검사를 한다.
|
||||
///
|
||||
/// 현장 단말이 1920x1080 이상이라 기본 크기(1440x920)로는 이 문제가 나지 않는다.
|
||||
/// 그래도 클램프를 두는 이유는, 저장을 시작하는 순간 <b>사용자가 만든 값</b>이 복원되기 때문이다 —
|
||||
/// 기본값이 안전한 것과 저장된 값이 안전한 것은 다른 문제다.
|
||||
/// </summary>
|
||||
public static class WindowPlacement
|
||||
{
|
||||
#region Member Fields
|
||||
private const string KeyLeft = "Window.Left";
|
||||
private const string KeyTop = "Window.Top";
|
||||
private const string KeyWidth = "Window.Width";
|
||||
private const string KeyHeight = "Window.Height";
|
||||
private const string KeyMaximized = "Window.Maximized";
|
||||
private const string KeyLeftPanel = "Panel.Left.Width";
|
||||
private const string KeyRightPanel = "Panel.Right.Width";
|
||||
|
||||
/// <summary>제목 표시줄을 잡을 수 있어야 하는 최소 노출 폭·높이</summary>
|
||||
private const double Grip = 120;
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 저장된 사각형을 복원한다 — 화면 밖이면 버리고 기본값을 쓴다.
|
||||
/// <c>Show()</c> 전에 불러야 한다(보인 뒤에 옮기면 한 프레임 번쩍인다).
|
||||
/// </summary>
|
||||
public static void Restore(Window window)
|
||||
{
|
||||
var width = UserPrefs.GetDouble(KeyWidth, window.Width);
|
||||
var height = UserPrefs.GetDouble(KeyHeight, window.Height);
|
||||
var left = UserPrefs.GetDouble(KeyLeft, double.NaN);
|
||||
var top = UserPrefs.GetDouble(KeyTop, double.NaN);
|
||||
|
||||
window.Width = Math.Max(window.MinWidth, width);
|
||||
window.Height = Math.Max(window.MinHeight, height);
|
||||
|
||||
if (double.IsNaN(left) || double.IsNaN(top))
|
||||
{
|
||||
return;
|
||||
}
|
||||
var work = SystemParameters.WorkArea;
|
||||
var rect = new Rect(left, top, window.Width, window.Height);
|
||||
// 어느 화면에도 충분히 걸치지 않으면 저장값을 버린다. 여러 모니터를 정확히 다루려면
|
||||
// 모니터 열거가 필요하지만, 주 화면 작업영역과의 교차만으로도 '손에 닿지 않는 창'은 막힌다.
|
||||
if (rect.Right < work.Left + Grip || rect.Left > work.Right - Grip
|
||||
|| rect.Bottom < work.Top + Grip || rect.Top > work.Bottom - Grip)
|
||||
{
|
||||
return;
|
||||
}
|
||||
window.WindowStartupLocation = WindowStartupLocation.Manual;
|
||||
window.Left = left;
|
||||
// 위로는 절대 넘기지 않는다 — 제목 표시줄이 화면 밖으로 나가면 되돌릴 수 없다
|
||||
window.Top = Math.Max(work.Top, top);
|
||||
|
||||
if (UserPrefs.GetBool(KeyMaximized, false))
|
||||
{
|
||||
window.WindowState = WindowState.Maximized;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>지금 상태를 저장한다 — 창을 닫을 때 한 번 부른다</summary>
|
||||
public static void Save(Window window, double leftPanel, double rightPanel)
|
||||
{
|
||||
UserPrefs.SetBool(KeyMaximized, window.WindowState == WindowState.Maximized);
|
||||
// 최대화 중이면 Left/Top/Width/Height 는 '복원했을 때의 값'이라 그대로 쓰는 것이 맞다.
|
||||
// 최소화 중이면 그 값이 쓰레기이므로 건드리지 않는다.
|
||||
if (window.WindowState == WindowState.Minimized)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var bounds = window.WindowState == WindowState.Maximized
|
||||
? window.RestoreBounds
|
||||
: new Rect(window.Left, window.Top, window.ActualWidth, window.ActualHeight);
|
||||
UserPrefs.SetDoubles(
|
||||
(KeyLeft, bounds.Left), (KeyTop, bounds.Top),
|
||||
(KeyWidth, bounds.Width), (KeyHeight, bounds.Height),
|
||||
(KeyLeftPanel, leftPanel), (KeyRightPanel, rightPanel));
|
||||
}
|
||||
|
||||
/// <summary>패널 폭 복원 — 0 이면 XAML 기본값을 그대로 둔다는 뜻</summary>
|
||||
public static double LeftPanelWidth(double fallback) => UserPrefs.GetDouble(KeyLeftPanel, fallback);
|
||||
|
||||
public static double RightPanelWidth(double fallback) => UserPrefs.GetDouble(KeyRightPanel, fallback);
|
||||
#endregion
|
||||
}
|
||||
@@ -28,6 +28,18 @@
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="FontSize" Value="13" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<!--
|
||||
화면 글자는 Display 로 그린다. WPF 기본값인 Ideal 은 글리프를 픽셀 격자에 맞추지 않고
|
||||
소수 위치에 놓아서, 11~14px UI 글자가 눌린 듯 흐려 보인다.
|
||||
<b>ClearType 과는 다른 축</b>이다 — Ideal 이든 Display 든 색 번짐은 그대로 생기므로
|
||||
색 번짐을 세는 진단으로는 이 흐림을 잡을 수 없었다(그래서 두 번 놓쳤다).
|
||||
|
||||
상속 속성이라 여기 한 번 걸면 TextBlock·TextBox·버튼·표 글자에 모두 닿는다.
|
||||
<b>종이는 예외다.</b> PageView 의 용지 Grid 와 PrintService 의 캔버스가 각각
|
||||
Ideal 을 명시하고 그 안의 글자는 그것을 상속한다 — 인쇄 지면 자릿수가 바뀌면
|
||||
레거시와 다른 종이가 된다(진단 db-render 의 md5 가 그것을 잠그고 있다).
|
||||
-->
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Display" />
|
||||
</Style>
|
||||
|
||||
<!-- 순수 Window 인스턴스용(파생 클래스에는 위 키 스타일을 직접 걸어야 한다) -->
|
||||
@@ -37,7 +49,12 @@
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Ink}" />
|
||||
<Setter Property="FontFamily" Value="Malgun Gothic" />
|
||||
<Setter Property="TextOptions.TextFormattingMode" Value="Ideal" />
|
||||
<!--
|
||||
TextFormattingMode 을 여기서 정하지 않는다. <b>스타일 세터는 상속값을 이긴다</b> —
|
||||
여기에 값을 박으면 종이 안의 글자까지 그 값이 되어 용지 Grid 가 정한 Ideal 을 덮는다.
|
||||
전에 Ideal 이 박혀 있어서 화면 UI 글자가 전부 흐렸다.
|
||||
지금은 창(Display)과 종이(Ideal)가 각각 상속으로 정한다.
|
||||
-->
|
||||
</Style>
|
||||
|
||||
<!-- ===== 버튼 (기본: 은은한 다크 채움) ===== -->
|
||||
@@ -55,7 +72,10 @@
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
|
||||
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
|
||||
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
@@ -98,7 +118,10 @@
|
||||
<ControlTemplate TargetType="Button">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
|
||||
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
|
||||
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
@@ -115,8 +138,16 @@
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.AccentFillPressed}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.AccentFillPressed}" />
|
||||
</Trigger>
|
||||
<!--
|
||||
비활성은 <b>색을 빼서</b> 알린다. 전에는 불투명도만 0.45 로 내렸는데,
|
||||
그러면 강조색이 옅어질 뿐 여전히 파랗고 그 위의 흰 글자가 배경에 묻힌다
|
||||
(환자 선택 창의 '선택' 버튼이 그 상태로 뜬다 — 처음부터 비활성이다).
|
||||
"누를 수 있어 보이는데 안 눌린다"와 "글자가 안 읽힌다"를 한꺼번에 없앤다.
|
||||
-->
|
||||
<Trigger Property="IsEnabled" Value="False">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.45" />
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Input}" />
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.InputBorder}" />
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}" />
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
@@ -557,7 +588,17 @@
|
||||
<Border.Resources>
|
||||
<Style TargetType="ComboBoxItem" BasedOn="{StaticResource B.ComboItem}" />
|
||||
</Border.Resources>
|
||||
<ScrollViewer MaxHeight="260"><ItemsPresenter /></ScrollViewer>
|
||||
<!-- 안쪽에 불투명 배경을 한 겹 더 두고 여기에 ClearTypeHint 를 건다.
|
||||
Effect 와 AllowsTransparency 는 각각 중간 서피스를 만들어 ClearType 을 끄고,
|
||||
그러면 13px 한글이 회색 안티에일리어싱으로 뭉개진다.
|
||||
힌트를 Effect 가 걸린 바깥 Border 에 걸면 듣지 않는다.
|
||||
중간 서피스 안쪽의 불투명 배경에 걸어야 한다(진단 cleartype 으로 잰 결과). -->
|
||||
<ScrollViewer MaxHeight="260">
|
||||
<Grid Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<ItemsPresenter />
|
||||
</Grid>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
@@ -637,11 +678,12 @@
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1" Padding="8,5,8,0">
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Disabled">
|
||||
<StackPanel Orientation="Horizontal" VerticalAlignment="Bottom">
|
||||
<!--
|
||||
탭 흐름만 둔다. 전에는 옆에 '새 서식 탭(+)' 버튼이 있었으나
|
||||
서식은 반드시 목록에서 골라 연다 — 코드 없는 빈 문서는
|
||||
DB 에 저장할 수 없어서, 만들 수 있게 두면 그린 뒤에 막힌다.
|
||||
-->
|
||||
<StackPanel Orientation="Horizontal" IsItemsHost="True" />
|
||||
<!-- 새 서식 탭(+) — 탭 흐름 바로 옆([200] 문서탭 관행) -->
|
||||
<Button Content="+" Command="{Binding NewFileCommand}" Style="{StaticResource Subtle}"
|
||||
Width="26" Height="24" Padding="0" Margin="2,0,0,3" FontSize="13"
|
||||
VerticalAlignment="Center" ToolTip="새 서식 탭"/>
|
||||
</StackPanel>
|
||||
</ScrollViewer>
|
||||
</Border>
|
||||
@@ -711,7 +753,10 @@
|
||||
<ControlTemplate TargetType="ToggleButton">
|
||||
<Border x:Name="bd" Background="{TemplateBinding Background}" BorderBrush="{TemplateBinding BorderBrush}"
|
||||
BorderThickness="1" CornerRadius="5" Padding="{TemplateBinding Padding}" SnapsToDevicePixels="True">
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center">
|
||||
<!-- RecognizesAccessKey — 없으면 Content="찾기(_F)" 가 밑줄 대신 <b>_F 글자 그대로</b> 나오고
|
||||
Alt+F 도 먹지 않는다. 태그명(PAT_이름)을 싣는 칩 버튼은 자기 템플릿이 따로
|
||||
있어 여기에 걸리지 않는다 — 거기서 켜면 이름이 잘려 보인다. -->
|
||||
<ContentPresenter HorizontalAlignment="Center" VerticalAlignment="Center" RecognizesAccessKey="True">
|
||||
<ContentPresenter.Resources>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{Binding Foreground, RelativeSource={RelativeSource AncestorType=ButtonBase}}" />
|
||||
@@ -1096,8 +1141,12 @@
|
||||
</Border>
|
||||
<Popup x:Name="pop" Placement="Bottom" IsOpen="{TemplateBinding IsSubmenuOpen}" AllowsTransparency="True"
|
||||
Focusable="False" PopupAnimation="Fade">
|
||||
<!-- AllowsTransparency 팝업은 레이어드 창이라 ClearType 이 꺼진다.
|
||||
불투명 배경이 있는 이 자리에 힌트를 걸어 되살린다 —
|
||||
메뉴에 배치·정렬 명령이 다 들어 있어 가장 많이 읽는 글자다. -->
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0">
|
||||
CornerRadius="6" Padding="4" Margin="0,3,0,0"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<StackPanel IsItemsHost="True" />
|
||||
</Border>
|
||||
</Popup>
|
||||
@@ -1372,4 +1421,68 @@
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
|
||||
<!-- ============================================================
|
||||
대화상자 크롬 — 앱 셸·알림창과 같은 타이틀바를 <b>모든</b> 창에 준다.
|
||||
|
||||
<b>왜 필요했나.</b> 커스텀 크롬을 가진 창은 메인 셸과 MessageDialogView 둘뿐이었고,
|
||||
나머지 열한 창(서식 이력·데이터소스 배선·글꼴 관리자·태그 선택·쿼리 편집기·환자 선택 …)은
|
||||
윈도우 <b>기본</b> 타이틀바를 달고 떴다. 다크 테마에서 어두운 앱 위에 OS 바가 얹히고,
|
||||
모서리·높이·글자 크기·닫기 버튼 모양이 전부 달라 "다른 프로그램이 열렸다"로 보였다.
|
||||
|
||||
<b>왜 스타일 하나로 하나.</b> 열한 창은 이미 전부 ThemedWindow 를 걸고 있었다 —
|
||||
템플릿을 여기 한 번 두면 각 창은 스타일 이름 한 줄만 바꾸면 된다.
|
||||
창마다 타이틀바 markup 을 복사하면 다음에 또 갈라진다.
|
||||
|
||||
AllowsTransparency 는 켜지 않는다: 레이어드 윈도우가 되면 ClearType 이 꺼져
|
||||
13px 한글이 뭉개지고 진단이 찍는 PNG 배경이 알파가 된다.
|
||||
닫기 버튼의 명령 배선과 최대화 보정은 ctl:DialogChrome 이 붙여 준다.
|
||||
============================================================ -->
|
||||
<Style x:Key="ThemedDialogWindow" TargetType="Window" BasedOn="{StaticResource ThemedWindow}">
|
||||
<Setter Property="WindowStyle" Value="None" />
|
||||
<Setter Property="Background" Value="{DynamicResource B.Surface}" />
|
||||
<!--
|
||||
WindowChrome 은 여기서 못 준다. Freezable 인 데다 창에 붙는 순간 스스로 속성을 고치는데,
|
||||
Style 의 Setter.Value 는 <b>한 번 평가돼 공유</b>되므로 두 번째 창부터
|
||||
"읽기 전용 상태라 속성을 설정할 수 없습니다"로 죽는다(x:Shared="False" 로도 안 된다 —
|
||||
공유되는 것은 리소스가 아니라 Setter 가 이미 붙든 값이다).
|
||||
그래서 ctl:DialogChrome 이 <b>창마다 새 인스턴스</b>를 만들어 붙인다.
|
||||
-->
|
||||
<Setter Property="ctl:DialogChrome.Enabled" Value="True" />
|
||||
<Setter Property="Template">
|
||||
<Setter.Value>
|
||||
<ControlTemplate TargetType="Window">
|
||||
<!-- PART_Root — 진단(dialog-shots)이 창 전체를 찍을 때 잡는 이름.
|
||||
이 이름이 없으면 Content 만 찍혀 타이틀바가 사진에서 빠진다. -->
|
||||
<Border x:Name="PART_Root"
|
||||
BorderBrush="{DynamicResource B.Line2}" BorderThickness="1"
|
||||
Background="{DynamicResource B.Surface}">
|
||||
<DockPanel>
|
||||
<!-- 타이틀바 — 드래그는 WindowChrome 의 CaptionHeight 가 처리한다 -->
|
||||
<Border DockPanel.Dock="Top" Height="36" Background="{DynamicResource B.Titlebar}"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<Grid>
|
||||
<TextBlock Text="{TemplateBinding Title}" Margin="14,0,52,0"
|
||||
VerticalAlignment="Center" FontSize="12"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
TextTrimming="CharacterEllipsis" />
|
||||
<!-- IsHitTestVisibleInChrome 필수 — 빠지면 클릭이 창 드래그로 먹혀 닫을 수 없다 -->
|
||||
<Button Style="{StaticResource CloseBtn}" Height="36"
|
||||
HorizontalAlignment="Right" IsTabStop="False" ToolTip="닫기"
|
||||
Command="{x:Static SystemCommands.CloseWindowCommand}"
|
||||
shell:WindowChrome.IsHitTestVisibleInChrome="True">
|
||||
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10" />
|
||||
</Button>
|
||||
</Grid>
|
||||
</Border>
|
||||
<!-- AdornerDecorator 가 있어야 검증 오류 표시·툴팁 어도너가 정상 동작한다 -->
|
||||
<AdornerDecorator>
|
||||
<ContentPresenter />
|
||||
</AdornerDecorator>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
</Setter>
|
||||
</Style>
|
||||
|
||||
</ResourceDictionary>
|
||||
|
||||
@@ -199,6 +199,33 @@ public abstract class ControlViewModel : ViewModelBase, ILayerRow
|
||||
/// <summary>이름 칸을 그릴지 — 문구와 이름이 다를 때만</summary>
|
||||
public bool HasLayerId => LayerId.Length > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 행 둘째 줄 — <b>항상</b> 있다. 그래야 행 높이가 일정하다.
|
||||
///
|
||||
/// 전에는 표시명이 곧 Id 인 행(문구가 없는 컨트롤)에서 둘째 줄이 통째로 빠져
|
||||
/// 목록에 1줄과 2줄이 섞였다. 훑을 때 리듬이 깨지고, 어느 행이 무엇인지
|
||||
/// 눈이 매번 다시 자리를 잡아야 했다.
|
||||
///
|
||||
/// <b>타입명을 앞에 둔다.</b> 아이콘만으로는 타입이 안 갈리는 짝이 실제로 있다 —
|
||||
/// 라벨과 마스크입력이 같은 글리프를 쓰고, 리스트박스와 체크리스트도 같다.
|
||||
/// 이름(Id)은 표시명과 다를 때만 뒤에 붙인다 — 같으면 윗줄과 같은 글자를 두 번 쓰는 셈이다.
|
||||
/// 이름을 계속 보여 주는 이유는 그것이 <b>외부 참조 키</b>이기 때문이다(수식·액션 대상·이벤트 규약).
|
||||
/// </summary>
|
||||
public string LayerSubText
|
||||
{
|
||||
get
|
||||
{
|
||||
var typeName = SheetMe.Core.Catalog.ControlRegistry.Find(Type)?.DisplayName ?? Type;
|
||||
// 이름이 곧 표시명이면(문구 없는 컨트롤) 타입만 — 같은 글자를 두 번 쓰지 않는다
|
||||
if (!HasLayerId)
|
||||
{
|
||||
return typeName;
|
||||
}
|
||||
// 갓 놓은 컨트롤은 문구가 타입명 그대로다("라디오버튼") — 윗줄이 이미 타입을 말했으므로 이름만
|
||||
return LayerName == typeName ? LayerId : $"{typeName} · {LayerId}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>레이어 잠금 토글 아이콘명</summary>
|
||||
public string LockIcon => Model.Locked ? "lock" : "unlock";
|
||||
|
||||
|
||||
@@ -136,6 +136,30 @@ public abstract class ContainerViewModel : ControlViewModel
|
||||
/// <summary>레이어 목록에서 펼침 셰브론을 그릴지 — 자식이 있을 때만</summary>
|
||||
public override bool HasLayerChildren => Children.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 레이어 목록에 붙는 자식 수 — 접혀 있어도 <b>무엇이 딸려 있는지</b> 알 수 있어야 한다.
|
||||
///
|
||||
/// 페이지 행에는 (7), 그룹 폴더에는 (3)이 이미 붙는데 컨테이너만 없었다.
|
||||
/// 그래서 접힌 패널이 3개를 품었는지 300개를 품었는지 펴 보기 전에는 알 수 없었고,
|
||||
/// 그 패널을 지우거나 옮기기 전에 무엇이 함께 가는지도 알 수 없었다.
|
||||
/// 중첩까지 세어 실제로 딸려 가는 수를 말한다.
|
||||
/// </summary>
|
||||
public string LayerChildCountText => $"({CountAll(Children)})";
|
||||
|
||||
private static int CountAll(IEnumerable<ControlViewModel> items)
|
||||
{
|
||||
var total = 0;
|
||||
foreach (var item in items)
|
||||
{
|
||||
total++;
|
||||
if (item is ContainerViewModel container)
|
||||
{
|
||||
total += CountAll(container.Children);
|
||||
}
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
protected ContainerViewModel(ControlElement model) : base(model) { }
|
||||
|
||||
/// <summary>시각 재해석 — 자식에게 상속 컨텍스트 전파(WinForms Font/ForeColor 상속)</summary>
|
||||
@@ -180,6 +204,59 @@ public sealed class PictureBoxViewModel : ControlViewModel
|
||||
/// <summary>디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트</summary>
|
||||
public string Hint => Prop("DataInterfaceTag") ?? "이미지";
|
||||
|
||||
/// <summary>
|
||||
/// 그릴 이미지 — 해석기가 정한 파일 경로(<c>ResolvedImagePath</c>)를 읽는다.
|
||||
/// 캔버스(편집 화면)에는 그 속성이 없어 null 이고 자리표시자가 남는다.
|
||||
///
|
||||
/// <b>파일을 잠그지 않는다</b>(OnLoad + 스트림 복사). 레거시도 같은 이유로
|
||||
/// Image.FromFile 대신 바이트를 읽어 메모리에서 디코딩한다(MPictureBox.vb:429-572) —
|
||||
/// 잠그면 그 파일을 쓰는 다른 프로그램이 막힌다.
|
||||
/// 못 읽으면 null 이다(깨진 파일·권한) — 레거시도 빈 catch 로 삼켜 빈칸이 됐다.
|
||||
/// </summary>
|
||||
public System.Windows.Media.ImageSource? Image
|
||||
{
|
||||
get
|
||||
{
|
||||
var path = Prop("ResolvedImagePath");
|
||||
if (path is not { Length: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
try
|
||||
{
|
||||
var image = new System.Windows.Media.Imaging.BitmapImage();
|
||||
image.BeginInit();
|
||||
image.CacheOption = System.Windows.Media.Imaging.BitmapCacheOption.OnLoad;
|
||||
image.CreateOptions = System.Windows.Media.Imaging.BitmapCreateOptions.IgnoreImageCache;
|
||||
image.UriSource = new Uri(path, UriKind.Absolute);
|
||||
image.EndInit();
|
||||
image.Freeze();
|
||||
return image;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 채우기 방식 — 레거시 SizeMode 대응.
|
||||
///
|
||||
/// <b>인쇄는 레거시가 SizeMode 를 무시하고 컨트롤 사각형에 늘려 그린다</b>
|
||||
/// (MPictureBox.vb:707-783 PrintMe/DrawingControl). 종이를 레거시와 맞추는 것이
|
||||
/// 이 프로그램의 기준이므로 기본을 Fill 로 두고, 비율 유지를 명시한 값만 존중한다.
|
||||
/// </summary>
|
||||
public System.Windows.Media.Stretch ImageStretch => Prop("SizeMode") switch
|
||||
{
|
||||
"Zoom" => System.Windows.Media.Stretch.Uniform,
|
||||
"CenterImage" or "AutoSize" or "Normal" => System.Windows.Media.Stretch.None,
|
||||
_ => System.Windows.Media.Stretch.Fill,
|
||||
};
|
||||
|
||||
/// <summary>이미지가 없을 때만 자리표시자를 보인다</summary>
|
||||
public bool HasImage => Image is not null;
|
||||
|
||||
public PictureBoxViewModel(ControlElement model) : base(model) { }
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
private const double PageGap = 28;
|
||||
private const double WorldPad = 24;
|
||||
private static readonly List<ControlElement> clipboard = new();
|
||||
/// <summary>한 계단에 몇 번 내려간 뒤 옆으로 비키는가 — 종이를 벗어나기 전에 접는다</summary>
|
||||
private const int PlacementLaneSteps = 12;
|
||||
|
||||
/// <summary>팔레트 즉시 배치 계단 — 지우고 다시 놓아도 같은 자리로 돌아가지 않게 단조 증가한다</summary>
|
||||
private int placementStep;
|
||||
|
||||
private double zoom = 1.0;
|
||||
private FormDocument document;
|
||||
private PageViewModel? selectedPage;
|
||||
@@ -123,6 +129,21 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// <summary>탭 헤더 갱신 통지(저장/개명 후)</summary>
|
||||
public void NotifyDisplayNameChanged() => OnPropertyChanged(nameof(DisplayName));
|
||||
|
||||
/// <summary>
|
||||
/// 저장하지 않은 변경이 있는가 — <b>탭에 점으로 보인다</b>.
|
||||
///
|
||||
/// 이 상태는 <see cref="UndoService.IsDirty"/> 가 저장 지점 깊이까지 추적해 정교하게 들고
|
||||
/// 있었는데(되돌리기로 저장 지점에 돌아오면 자동으로 깨끗해진다) 화면에는 한 번도 나오지 않았다.
|
||||
/// 서식 셋을 열어 둘을 고치고 하나는 보기만 한 뒤에는, 어느 것이 안 저장됐는지
|
||||
/// <b>확인할 방법이 없다</b> — 탭 제목이 저장 전후로 글자 하나 다르지 않다.
|
||||
/// 그래서 안전하게 전부 다시 저장하게 되는데, DB 저장은 <b>새 버전을 만든다</b> —
|
||||
/// 서식마다 쓸데없는 버전이 쌓인다.
|
||||
/// </summary>
|
||||
public bool IsDirty => Undo.IsDirty;
|
||||
|
||||
/// <summary>더티 표시 갱신 통지 — 편집·저장·되돌리기 뒤에 부른다</summary>
|
||||
public void NotifyDirtyChanged() => OnPropertyChanged(nameof(IsDirty));
|
||||
|
||||
/// <summary>페이지 VM 목록</summary>
|
||||
public ObservableCollection<PageViewModel> Pages { get; } = new();
|
||||
|
||||
@@ -284,6 +305,8 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
ScreenTolerance = ConfigService.Current.SnapThreshold,
|
||||
};
|
||||
Undo = new UndoService(() => Document, ReplaceDocument);
|
||||
// 저장 안 됨 표시가 탭에 따라오게 한다 — 값이 바뀔 때만 울린다
|
||||
Undo.DirtyChanged += NotifyDirtyChanged;
|
||||
Interaction = new InteractionController(this);
|
||||
Inspector = new InspectorViewModel(this);
|
||||
Selection.Changed += RefreshOverlay;
|
||||
@@ -308,6 +331,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
UngroupCommand = new Command((sender, e) => UngroupSelection());
|
||||
CutCommand = new Command((sender, e) => CutSelection());
|
||||
SizeToControlCommand = new Command((object param) => SizeToControl(param as string ?? "Both"));
|
||||
SelectSameCommand = new Command((object param) =>
|
||||
{
|
||||
if (Enum.TryParse<SameKind>(param as string, out var kind))
|
||||
{
|
||||
SelectSame(kind);
|
||||
}
|
||||
});
|
||||
LockCommand = new Command((sender, e) => ToggleLockSelection());
|
||||
HideCommand = new Command((sender, e) => ToggleHiddenSelection());
|
||||
SpacingCommand = new Command((object param) => AdjustSpacing(param as string ?? string.Empty));
|
||||
CenterInPageCommand = new Command((object param) => CenterInPage(param as string ?? "H"));
|
||||
TabOrderCommand = new Command((sender, e) => ToggleTabOrderMode());
|
||||
@@ -365,6 +397,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// <summary>같은 크기로(파라미터: Both/Width/Height — 기준 컨트롤에 맞춤)</summary>
|
||||
public ICustomCommand? SizeToControlCommand { get; set; }
|
||||
|
||||
/// <summary>같은 행·열·종류·크기로 선택 넓히기 — 인자는 <see cref="SameKind"/> 이름</summary>
|
||||
public ICustomCommand? SelectSameCommand { get; set; }
|
||||
|
||||
/// <summary>선택 잠그기/풀기</summary>
|
||||
public ICustomCommand? LockCommand { get; set; }
|
||||
|
||||
/// <summary>선택 숨기기/보이기</summary>
|
||||
public ICustomCommand? HideCommand { get; set; }
|
||||
|
||||
/// <summary>간격 조절(파라미터: HorizEqual/HorizIncrease/HorizDecrease/HorizConcat/VertEqual/...)</summary>
|
||||
public ICustomCommand? SpacingCommand { get; set; }
|
||||
|
||||
@@ -571,11 +612,58 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>문서 교체 후 재구성 — Undo 복원 경로</summary>
|
||||
/// <summary>
|
||||
/// 문서 교체 후 재구성 — Undo 복원 경로.
|
||||
///
|
||||
/// <b>선택을 되살린다.</b> 재구성의 첫 줄이 <c>Selection.Clear()</c> 라, 전에는 되돌리기 한 번마다
|
||||
/// 선택이 통째로 증발했다. 여러 개를 함께 고쳐 보는 작업의 흐름이
|
||||
/// '여럿 고르기 → 바꾸기 → 눈으로 확인 → Ctrl+Z → 다른 값으로 다시' 인데
|
||||
/// 첫 되돌리기에서 끊기고, 마퀴로 만든 열두 개 선택을 매번 다시 만들어야 했다.
|
||||
///
|
||||
/// 기준은 Id 다 — 문서 안에서 유일하고(<c>IdGenerator.CollectUsed</c>) 문서를 갈아끼워도 같다.
|
||||
/// 못 찾은 것은 조용히 뺀다(삭제를 다시 실행한 경우처럼 정말 없어졌을 수 있다).
|
||||
/// </summary>
|
||||
public void ReplaceDocument(FormDocument newDocument)
|
||||
{
|
||||
var keepIds = Selection.Items.Select(vm => vm.Id).ToList();
|
||||
var primaryId = Selection.Primary?.Id;
|
||||
|
||||
document = newDocument;
|
||||
RebuildFromDocument();
|
||||
|
||||
if (keepIds.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var wanted = new HashSet<string>(keepIds, StringComparer.OrdinalIgnoreCase);
|
||||
var found = new List<ControlViewModel>();
|
||||
ControlViewModel? primary = null;
|
||||
void Visit(IEnumerable<ControlViewModel> controls)
|
||||
{
|
||||
foreach (var vm in controls)
|
||||
{
|
||||
if (wanted.Contains(vm.Id))
|
||||
{
|
||||
found.Add(vm);
|
||||
if (primaryId is not null && string.Equals(vm.Id, primaryId, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
primary = vm;
|
||||
}
|
||||
}
|
||||
if (vm is ContainerViewModel container)
|
||||
{
|
||||
Visit(container.Children);
|
||||
}
|
||||
}
|
||||
}
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
Visit(page.Controls);
|
||||
}
|
||||
if (found.Count > 0)
|
||||
{
|
||||
Selection.Set(found, primary ?? found[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>페이지 세로 오프셋 재계산</summary>
|
||||
@@ -711,6 +799,37 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
return HitInList(page.Controls, local);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 이 좌표에 <b>무엇이든</b> 있는가 — 잠긴 것·숨긴 것까지 포함해서.
|
||||
///
|
||||
/// <see cref="HitTestControl"/> 은 잠금·숨김을 후보에서 아예 빼므로 그 위를 "빈 곳"이라고 답한다.
|
||||
/// 선택을 푸는 판단에는 그 답을 쓰면 안 된다 — 잠가 둔 배경 괘선 위에서 우클릭할 때마다
|
||||
/// 선택이 사라져 "왜 자꾸 풀리지"가 된다. 잠금은 <b>편집 보호</b>지 부재가 아니다.
|
||||
/// </summary>
|
||||
public bool AnyControlAt(Point world)
|
||||
{
|
||||
var page = PageAt(world.Y);
|
||||
if (page is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
return AnyInList(page.Controls, new Point(world.X, world.Y - page.OffsetY));
|
||||
}
|
||||
|
||||
private static bool AnyInList(IList<ControlViewModel> controls, Point local)
|
||||
{
|
||||
foreach (var vm in controls)
|
||||
{
|
||||
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
|
||||
if (!rect.Contains(local))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
private static ControlViewModel? HitInList(IList<ControlViewModel> controls, Point local)
|
||||
{
|
||||
for (var i = controls.Count - 1; i >= 0; i--)
|
||||
@@ -745,30 +864,110 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
var result = new List<ControlViewModel>();
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
foreach (var vm in page.Controls)
|
||||
CollectIntersecting(page.Controls, worldRect, result);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마퀴에 걸리는 것을 모은다 — <b>컨테이너 안에 그린 마퀴는 그 안의 것을 고른다</b>.
|
||||
///
|
||||
/// 전에는 최상위만 훑어서, 패널 안 체크박스 5개를 한 번에 고르려고 패널 안쪽을 끌면
|
||||
/// 패널만 잡히거나 아무것도 안 잡혔다 — 결국 Ctrl+클릭 반복이나 레이어 목록으로 우회했다.
|
||||
/// 컨테이너 자식은 전체 컨트롤의 18.3%(실측 18,749/컨테이너 4,099개)라 드문 경우가 아니다.
|
||||
///
|
||||
/// <b>규칙은 '담김'이다.</b> 마퀴가 컨테이너 사각형 <b>안에</b> 들어가 있으면
|
||||
/// 사용자는 그 안의 것을 고르려는 것이다(내 상자가 패널보다 작으니까).
|
||||
/// 걸치기만 하면 예전처럼 컨테이너 자체가 잡힌다 — 패널을 통째로 고르는 조작이 그대로 남는다.
|
||||
/// 모드도 수정키도 없이 그린 모양만으로 갈리므로 예측할 수 있다.
|
||||
/// </summary>
|
||||
private void CollectIntersecting(IEnumerable<ControlViewModel> controls, Rect worldRect,
|
||||
List<ControlViewModel> result)
|
||||
{
|
||||
foreach (var vm in controls)
|
||||
{
|
||||
if (vm.Model.Hidden || vm.Model.Locked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (worldRect.IntersectsWith(WorldBoundsOf(vm)))
|
||||
var bounds = WorldBoundsOf(vm);
|
||||
if (!worldRect.IntersectsWith(bounds))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (vm is ContainerViewModel container && bounds.Contains(worldRect))
|
||||
{
|
||||
CollectIntersecting(container.Children, worldRect, result);
|
||||
continue;
|
||||
}
|
||||
result.Add(vm);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 마퀴가 최종적으로 고를 것 — 그룹은 통째로 넓히되 <b>컨테이너 자식은 그대로 둔다</b>.
|
||||
///
|
||||
/// 클릭 선택은 <see cref="GroupMatesOf"/> 를 쓰는데 그것은 자식을 부모 컨테이너로 되돌린다
|
||||
/// (패널 안의 것을 집으면 패널이 움직여야 하므로 옳다). 마퀴에 그것을 그대로 쓰면
|
||||
/// 안쪽을 정확히 골라 놓고도 결과가 다시 패널 하나가 되어, 위 규칙이 아무 일도 하지 않는다.
|
||||
/// </summary>
|
||||
public List<ControlViewModel> MarqueeHits(Rect worldRect)
|
||||
{
|
||||
var hits = ControlsIntersecting(worldRect);
|
||||
var result = new List<ControlViewModel>();
|
||||
foreach (var vm in hits)
|
||||
{
|
||||
// 그룹(GroupId)은 페이지 최상위 개념이다 — 컨테이너 자식에는 붙지 않는다
|
||||
if (vm.Parent is null && vm.Model.GroupId is not null)
|
||||
{
|
||||
result.AddRange(GroupMatesOf(vm));
|
||||
continue;
|
||||
}
|
||||
return result;
|
||||
result.Add(vm);
|
||||
}
|
||||
return result.Distinct().ToList();
|
||||
}
|
||||
|
||||
/// <summary>오버레이(선택 박스/핸들) 재계산</summary>
|
||||
public void RefreshOverlay()
|
||||
{
|
||||
Overlay.UpdateSelection(SelectionWorldBounds(), Selection.Items.Count > 0);
|
||||
RefreshLockMarks();
|
||||
if (Selection.Primary is not null)
|
||||
{
|
||||
Selection.ActivePage = PageOf(Selection.Primary) ?? Selection.ActivePage;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 잠긴 컨트롤 표시를 다시 모은다 — 오버레이(편집 크롬) 층에만 그린다.
|
||||
///
|
||||
/// 종이 층에 넣으면 <c>--db-render</c> 게이트가 그것을 찍어 레거시와의 픽셀 동일성이 깨진다.
|
||||
/// 숨김은 0.25 투명이라 티가 나는데 잠금은 아무 표시가 없어, 클릭도 드래그도 안 되는 이유를
|
||||
/// 화면에서 알 수 없었다.
|
||||
/// </summary>
|
||||
private void RefreshLockMarks()
|
||||
{
|
||||
Overlay.LockMarks.Clear();
|
||||
foreach (var page in Pages)
|
||||
{
|
||||
foreach (var vm in ControlsOfPage(page))
|
||||
{
|
||||
if (!vm.Model.Locked || vm.Model.Hidden)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var world = WorldBoundsOf(vm);
|
||||
Overlay.LockMarks.Add(new LockMarkInfo
|
||||
{
|
||||
X = world.X,
|
||||
Y = world.Y,
|
||||
W = Math.Max(1, world.Width),
|
||||
H = Math.Max(1, world.Height),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Events
|
||||
@@ -787,6 +986,88 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// 있는지 알 수 없었다. 다중 페이지 서식이면 다른 페이지에 있을 수도 있어 더 그렇다.
|
||||
/// 스크롤은 뷰포트 크기를 알아야 하므로 캔버스 뷰에 위임한다.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 형제 중 다음(또는 이전) 컨트롤을 고른다 — Tab / Shift+Tab.
|
||||
///
|
||||
/// <b>왜 z-order 가 아니라 읽는 순서인가.</b> 계획서는 z-order 순회를 적었지만,
|
||||
/// 이 도구에서 Tab 을 누르는 이유는 "서식을 위에서 아래로 훑으며 문구를 고치는 것"이다.
|
||||
/// 기록지는 위에서 아래·왼쪽에서 오른쪽으로 읽으므로 그 순서가 손에 맞는다.
|
||||
/// z-order 는 겹침을 정하는 축이라 화면상 순서와 무관하게 튄다.
|
||||
/// (EMR 런타임 입력 순서인 <see cref="ApplyTabOrder"/> 와는 다른 개념이다 — 그건 저장되는 값이다.)
|
||||
///
|
||||
/// 범위는 <b>형제</b>다. 컨테이너 안으로는 Enter, 밖으로는 Esc 로 오간다 —
|
||||
/// 전체를 한 줄로 이으면 패널 안 라디오 15,026개를 지나야 다음 항목에 닿는다.
|
||||
/// </summary>
|
||||
public void SelectSibling(bool backward)
|
||||
{
|
||||
var page = Selection.ActivePage ?? SelectedPage ?? Pages.FirstOrDefault();
|
||||
if (page is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var current = Selection.Primary;
|
||||
var siblings = SiblingsOf(current, page);
|
||||
if (siblings.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 읽는 순서 — 같은 줄로 볼 만큼 가까우면(8px) X 로 가른다. 그리드가 4px 이라 그 두 칸이다.
|
||||
var ordered = siblings
|
||||
.OrderBy(c => Math.Round(c.Y / 8))
|
||||
.ThenBy(c => c.X)
|
||||
.ThenBy(c => c.Model.Id, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
|
||||
var index = current is null ? -1 : ordered.IndexOf(current);
|
||||
var next = index < 0
|
||||
? ordered[backward ? ^1 : 0]
|
||||
: ordered[((index + (backward ? -1 : 1)) % ordered.Count + ordered.Count) % ordered.Count];
|
||||
|
||||
Selection.SetSingle(next);
|
||||
// 눈에 보이지 않는 것을 고쳐 놓는 일이 생기지 않게 화면으로 데려온다 —
|
||||
// 레이어 목록 화살표 이동에 이것이 없어서 화면 밖 컨트롤을 눈 없이 편집할 수 있었다.
|
||||
RevealControl(next);
|
||||
}
|
||||
|
||||
/// <summary>Enter — 컨테이너면 안으로 들어가고, 글자를 담는 것이면 편집을 시작한다</summary>
|
||||
public bool EnterSelection()
|
||||
{
|
||||
if (Selection.Primary is not { } primary)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (primary is ContainerViewModel container && container.Children.FirstOrDefault() is { } child)
|
||||
{
|
||||
Selection.SetSingle(child);
|
||||
RevealControl(child);
|
||||
return true;
|
||||
}
|
||||
if (IsTextEditable(primary))
|
||||
{
|
||||
RequestInlineEdit(primary);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Esc — 컨테이너 밖으로 한 겹 나간다. 나갈 곳이 없으면 false(호출부가 선택을 푼다)</summary>
|
||||
public bool SelectParent()
|
||||
{
|
||||
if (Selection.Primary?.Parent is not { } parent)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Selection.SetSingle(parent);
|
||||
RevealControl(parent);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>이 컨트롤과 같은 부모를 가진 것들 — 부모가 없으면 페이지 최상위</summary>
|
||||
private List<ControlViewModel> SiblingsOf(ControlViewModel? viewModel, PageViewModel page)
|
||||
=> viewModel?.Parent is ContainerViewModel parent
|
||||
? parent.Children.ToList()
|
||||
: ControlsOfPage(page).Where(c => c.Parent is null).ToList();
|
||||
|
||||
public void RevealControl(ControlViewModel viewModel)
|
||||
{
|
||||
// 다른 페이지의 컨트롤이면 그 페이지를 활성으로 — 안 그러면 선택만 되고 화면은 안 따라간다
|
||||
@@ -1153,8 +1434,18 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
{
|
||||
return;
|
||||
}
|
||||
var offset = (page.Controls.Count % 8) * 16;
|
||||
AddControlAt(type, new Point(page.WidthDip / 2 + offset, page.OffsetY + page.HeightDip / 3 + offset));
|
||||
// 겹침 방지 오프셋은 <b>단조 증가하는 자체 카운터</b>로 센다.
|
||||
//
|
||||
// 전에는 (page.Controls.Count % 8) * 16 이었다. 두 가지가 잘못이다 —
|
||||
// ① % 8 이라 <b>9번째가 1번째와 정확히 같은 자리</b>에 떨어진다.
|
||||
// ② Controls.Count 를 쓰므로 하나 지우고 다시 놓으면 지운 것과 같은 자리로 간다.
|
||||
// 계단은 종이를 벗어나기 전에 접는다 — 접히더라도 방금 놓은 것 위로는 가지 않는다.
|
||||
var step = placementStep++;
|
||||
var lane = step / PlacementLaneSteps;
|
||||
var offset = (step % PlacementLaneSteps) * 16;
|
||||
AddControlAt(type, new Point(
|
||||
page.WidthDip / 2 + offset + (lane * 12),
|
||||
page.OffsetY + page.HeightDip / 3 + offset));
|
||||
}
|
||||
|
||||
/// <summary>팔레트 드롭 — 월드 좌표에 신규 컨트롤 생성(모델 index 0 = 최상위)</summary>
|
||||
@@ -1315,6 +1606,71 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
RebuildLayerRows();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택분을 <b>제자리</b>에 복제하고 복제본을 선택한다 — Ctrl+드래그 복사가 쓴다.
|
||||
///
|
||||
/// <b>붙여넣기와 다른 점</b>: 오프셋을 주지 않고, 원래 부모(컨테이너 자식이면 그 컨테이너)
|
||||
/// 안에 넣는다. 그래서 라디오를 Ctrl+드래그로 복사하면 <b>같은 패널 안</b>에 남아
|
||||
/// 같은 문항의 선택지가 하나 늘어난다 — 패널 밖으로 튀어나가면 배타 묶음이 갈라진다.
|
||||
///
|
||||
/// 표(Spread)는 복제하지 않는다(격자가 서식코드+이름으로 별도 테이블에서 오므로
|
||||
/// 복제본에는 격자가 없다 — <see cref="FilterPastable"/> 와 같은 이유).
|
||||
/// 복제에 실패하면(전부 표였다) false — 호출부가 이동으로 되돌린다.
|
||||
/// </summary>
|
||||
public bool DuplicateSelectionInPlace()
|
||||
{
|
||||
var sources = Selection.Items.ToList();
|
||||
if (sources.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var (pastable, _) = FilterPastable(sources.Select(vm => vm.Model).ToList());
|
||||
if (pastable.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var usedIds = IdGenerator.CollectUsed(document);
|
||||
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||
var copies = new List<ControlViewModel>();
|
||||
foreach (var vm in sources)
|
||||
{
|
||||
if (!pastable.Contains(vm.Model))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var page = PageOf(vm);
|
||||
if (page is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var clone = vm.Model.Clone();
|
||||
AssignNewIds(clone, usedIds);
|
||||
RemapGroupId(clone, groupRemap);
|
||||
if (vm.Parent is ContainerViewModel parent)
|
||||
{
|
||||
// 컨테이너 자식 — 같은 부모 안, 같은 상대좌표에 둔다
|
||||
parent.Model.Children.Insert(0, clone);
|
||||
var childVm = DocumentMapper.CreateControl(clone, parent.EffectiveFont, parent.Foreground);
|
||||
childVm.Parent = parent;
|
||||
parent.Children.Add(childVm);
|
||||
copies.Add(childVm);
|
||||
}
|
||||
else
|
||||
{
|
||||
page.Model.Controls.Insert(0, clone);
|
||||
copies.Add(CreateAndAttach(clone, page, attachToEnd: true));
|
||||
}
|
||||
}
|
||||
if (copies.Count == 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
Selection.Set(copies, copies.LastOrDefault());
|
||||
RebuildLayerRows();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>복제본의 그룹 식별자를 새 값으로 치환 — 같은 붙여넣기 안에서는 같은 그룹을 유지한다</summary>
|
||||
private void RemapGroupId(ControlElement element, Dictionary<string, string> remap)
|
||||
{
|
||||
@@ -1490,6 +1846,156 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택 확장의 기준 — 같은 줄로 볼 만큼 가까운가.
|
||||
///
|
||||
/// 정확히 같은 값만 묶으면 눈으로 맞춘 서식에서 <b>일부만</b> 잡힌다 — 8개 중 5개를 정렬해
|
||||
/// 놓고 나머지 3개가 남은 것을 못 알아채는 것이 가장 나쁜 결과다. 2px 는 이 서식의
|
||||
/// 컨트롤 높이(대부분 20px 이상)보다 훨씬 작아 이웃 줄을 삼키지 않는다.
|
||||
/// 격자 크기에 묶지 않는다 — 격자를 바꿨다고 선택 규칙이 달라지면 예측할 수 없다.
|
||||
/// </summary>
|
||||
private const double SelectSameTolerance = 2.0;
|
||||
|
||||
/// <summary>선택 확장의 종류 — 우클릭 메뉴·단축키가 공유한다</summary>
|
||||
public enum SameKind
|
||||
{
|
||||
/// <summary>같은 행(Y 가 같다)</summary>
|
||||
Row,
|
||||
/// <summary>같은 열(X 가 같다)</summary>
|
||||
Column,
|
||||
/// <summary>같은 종류(타입이 같다)</summary>
|
||||
Type,
|
||||
/// <summary>같은 크기(너비·높이가 같다)</summary>
|
||||
Size,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 주 선택과 <b>같은 줄·열·종류·크기</b>인 형제를 선택에 더한다 — 반환은 더해진 개수.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 정렬 6종·간격 4종·같은 크기가 이미 다 있는데, 그 명령들이 전제하는
|
||||
/// "올바른 선택"을 만들 수단이 없었다. 표 서식에서 가장 흔한 편집이
|
||||
/// "이 행의 입력칸 8개를 조금 높이고 아래로 붙이자"인데, 그 8개를 고르는 길이
|
||||
/// Ctrl+클릭 8번뿐이다. 마퀴는 <b>교차</b> 판정이라 행 띠를 스치는 것이 전부 딸려 오고,
|
||||
/// 실측(787건)으로 행 무리의 94.2%에 침입자가 있으며 그 수가 중앙값 7개·p90 41개다.
|
||||
/// 즉 지금은 "드래그 → 딸려온 것 빼기"가 매번 붙는다.
|
||||
///
|
||||
/// 범위는 <b>같은 부모의 형제</b>다 — 정렬 명령이 쓰는 범위와 같아야 결과를 예측할 수 있다.
|
||||
/// 순수 계산이라 종이 렌더·저장 형식에 전혀 닿지 않는다.
|
||||
/// </summary>
|
||||
public int SelectSame(SameKind kind)
|
||||
{
|
||||
var anchor = Selection.Primary ?? Selection.Items.FirstOrDefault();
|
||||
var page = (anchor is null ? null : PageOf(anchor)) ?? Selection.ActivePage ?? Pages.FirstOrDefault();
|
||||
if (anchor is null || page is null)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
static bool Near(double left, double right) => Math.Abs(left - right) <= SelectSameTolerance;
|
||||
|
||||
var matches = SiblingsOf(anchor, page).Where(candidate => kind switch
|
||||
{
|
||||
SameKind.Row => Near(candidate.Y, anchor.Y),
|
||||
SameKind.Column => Near(candidate.X, anchor.X),
|
||||
SameKind.Type => string.Equals(candidate.Type, anchor.Type, StringComparison.Ordinal),
|
||||
SameKind.Size => Near(candidate.Width, anchor.Width) && Near(candidate.Height, anchor.Height),
|
||||
_ => false,
|
||||
}).ToList();
|
||||
|
||||
// 잠금·숨김은 뺀다 — 편집하려고 넓히는 선택인데 편집할 수 없는 것이 섞이면
|
||||
// 정렬을 눌렀을 때 "왜 이건 안 움직이지"가 된다
|
||||
matches = matches.Where(c => !c.Model.Locked && !c.Model.Hidden).ToList();
|
||||
|
||||
var before = Selection.Items.Count;
|
||||
var merged = Selection.Items.Union(matches).Distinct().ToList();
|
||||
if (merged.Count == before)
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
Selection.Set(merged, anchor);
|
||||
return merged.Count - before;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택 크기를 키우거나 줄인다 — <see cref="NudgeSelection"/> 의 빠져 있던 짝.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 위치는 방향키로 1px 미세 조정이 되는데 크기만 그 수단이 없었다.
|
||||
/// 입력칸 폭을 3px 늘리려면 오른쪽 인스펙터 W 칸으로 가서 숫자를 읽고 3 을 더해 입력한다 —
|
||||
/// 캔버스와 300px 떨어진 패널 사이를 마우스로 왕복한다. 마우스 핸들은 판정 반경이
|
||||
/// 화면 6px 이라 작은 컨트롤에서 3px 를 정확히 집기 어렵고, 스냅이 붙으면 원하는 값에 못 선다.
|
||||
///
|
||||
/// 넛지와 같은 400ms 코얼레스를 쓴다 — 연타가 Undo 스무 개로 쌓이면 안 된다.
|
||||
/// </summary>
|
||||
public void ResizeSelection(double dw, double dh)
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.SnapshotForNudge();
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
if (vm.Model.Locked)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
vm.Width = Math.Max(1, Math.Round(vm.Width + dw));
|
||||
vm.Height = Math.Max(1, Math.Round(vm.Height + dh));
|
||||
// 선은 두께 축을 끌어서 바꿀 수 없다 — 레거시 MLine 이 매번 BorderWidth 로 되돌린다.
|
||||
// 허용하면 디자이너에만 두꺼운 선이 보이고 EMR 은 원래 두께로 그린다.
|
||||
if (SheetMe.Core.Catalog.LineGeometry.NormalizeThickness(vm.Model))
|
||||
{
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
}
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 선택을 잠그거나 푼다 — 하나라도 안 잠겼으면 전부 잠근다(레이어 패널의 묶음 토글과 같은 규칙).
|
||||
///
|
||||
/// <b>왜 캔버스에서 되어야 하나.</b> 배경 괘선 40개를 잠가 두고 그 위에서 라벨만 만지고 싶을 때,
|
||||
/// 지금은 좌측 패널을 레이어 탭으로 바꾸고 트리에서 40행을 찾아 13px 자물쇠를 40번 눌러야 한다.
|
||||
/// 캔버스에서 마퀴로 40개를 잡아 놔도 한 번에 잠글 수단이 없었다.
|
||||
///
|
||||
/// <b>잠금 해제는 캔버스에서 못 한다.</b> 히트 판정이 잠긴 것을 후보에서 빼므로 우클릭조차 닿지 않는다 —
|
||||
/// 해제 경로는 레이어 패널에 있다. 그래서 캔버스 메뉴 이름은 '선택 잠그기'로 한정한다.
|
||||
/// <b>잠금·숨김은 저장되지 않는 편집 전용 상태다</b> — 영속처럼 읽히는 문구를 쓰지 않는다.
|
||||
/// </summary>
|
||||
public void ToggleLockSelection()
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var locking = Selection.Items.Any(vm => !vm.Model.Locked);
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
vm.Model.Locked = locking;
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
RebuildLayerRows();
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>선택을 숨기거나 보인다 — 하나라도 보이면 전부 숨긴다</summary>
|
||||
public void ToggleHiddenSelection()
|
||||
{
|
||||
if (Selection.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
Undo.Snapshot();
|
||||
var hiding = Selection.Items.Any(vm => !vm.Model.Hidden);
|
||||
foreach (var vm in Selection.Items)
|
||||
{
|
||||
vm.Model.Hidden = hiding;
|
||||
vm.NotifyAllChanged();
|
||||
}
|
||||
RebuildLayerRows();
|
||||
RefreshOverlay();
|
||||
}
|
||||
|
||||
/// <summary>활성 페이지 전체 선택</summary>
|
||||
public void SelectAllOnActivePage()
|
||||
{
|
||||
@@ -1671,8 +2177,13 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
=> Selection.Items.Count > 0 && Selection.Items.Select(PageOf).Distinct().Count() <= 1;
|
||||
|
||||
/// <summary>
|
||||
/// 선택 그룹화 — 동일 GroupId 부여(2개 이상, 최상위 컨트롤만) + 기본 이름 등록.
|
||||
/// 선택 그룹화 — 선택분(2개 이상, 최상위만)을 감싸는 <b>진짜 Panel</b> 을 만들어 자식으로 옮긴다.
|
||||
/// 그래서 저장까지 살아남는다(레거시 XML 의 children="Controls" 중첩 Object).
|
||||
/// 잠긴 컨트롤은 애초에 선택에 들어오지 않는다(SelectionService 가 거른다).
|
||||
///
|
||||
/// <b>라디오에 특히 중요하다</b>: 레거시는 GroupName 이 없어 직계 부모가 곧 배타 단위이고,
|
||||
/// 운영 실측도 라디오 15,184개 중 15,026개(98.96%)가 패널 자식이다(그룹 5,582개, 평균 2.72개).
|
||||
/// 즉 이 조작이 "문항 만들기" 다.
|
||||
/// </summary>
|
||||
public void GroupSelection()
|
||||
{
|
||||
@@ -1686,6 +2197,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
DialogService.Notify(DialogKind.Warning, "그룹", reason);
|
||||
return;
|
||||
}
|
||||
// 라디오는 <b>패널이 곧 문항</b>이다 — 묶는 것이 정상이고 필요한 작업이다
|
||||
// (운영 실측: 라디오 9,935개 중 9,819개(98.8%)가 패널 자식, 그룹 3,625개).
|
||||
// 다만 한 문항의 일부만 묶으면 '하나만 고르기'가 둘로 갈라지므로 그때만 되묻는다.
|
||||
if (RadioSplitWarning(page, targets) is { } warning
|
||||
&& !DialogService.Confirm("그룹", warning,
|
||||
"묶은 라디오끼리 하나만 선택되고, 남은 라디오는 별도 묶음이 됩니다."))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// z 순서상 멤버가 차지한 구간 — 패널은 그 자리에 들어간다.
|
||||
// WinForms 에서 형제는 남의 자식 사이에 낄 수 없어 멤버가 반드시 연속 블록이 되는데,
|
||||
@@ -1739,15 +2259,7 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// </summary>
|
||||
private string? GroupBlockReason(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
|
||||
{
|
||||
// ① 라디오는 부모가 곧 배타 단위다(레거시 RadioButton 에 GroupName 이 없다).
|
||||
// '택1' 문항에서 일부만 묶으면 그 일부가 독립 집합이 되어 EMR 에서 답이 둘 선택된다.
|
||||
if (targets.Any(vm => vm.Type == "RadioButton"))
|
||||
{
|
||||
return "라디오 버튼은 패널이 곧 선택 묶음이라, 일부만 묶으면 '하나만 고르기'가 갈라집니다.\n"
|
||||
+ "라디오는 그룹으로 묶지 마세요.";
|
||||
}
|
||||
|
||||
// ② 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
|
||||
// 멤버 사이에 남이 끼어 있으면 쌓임 순서가 바뀐다.
|
||||
var indices = targets.Select(vm => page.Model.Controls.IndexOf(vm.Model)).ToList();
|
||||
if (indices.Any(i => i < 0))
|
||||
{
|
||||
@@ -1762,6 +2274,32 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 라디오 문항이 갈라지는가 — 갈라지지 않으면 null.
|
||||
///
|
||||
/// <b>왜 되묻는가.</b> 레거시 RadioButton 은 WinForms 를 그대로 상속해
|
||||
/// <c>GroupName</c> 이 없고 <b>직계 부모가 곧 배타 단위</b>다(RadioButton.vb:7-8).
|
||||
/// 그래서 패널로 묶는 것 자체는 정상이고 필요한 작업이지만,
|
||||
/// 같은 자리에 있던 라디오 중 <b>일부만</b> 묶으면 한 문항이 두 묶음으로 갈라져
|
||||
/// EMR 에서 답이 둘 선택된다 — 캔버스에는 아무 변화도 안 보인 채로.
|
||||
/// </summary>
|
||||
internal string? RadioSplitWarning(PageViewModel page, IReadOnlyList<ControlViewModel> targets)
|
||||
{
|
||||
var picked = targets.Count(vm => vm.Type == "RadioButton");
|
||||
if (picked == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// 지금은 최상위만 묶을 수 있으므로(위 targets 필터) 남는 형제도 최상위에서 센다
|
||||
var siblings = page.Model.Controls.Count(c =>
|
||||
string.Equals(c.Type, "RadioButton", StringComparison.Ordinal)
|
||||
&& !targets.Any(vm => ReferenceEquals(vm.Model, c)));
|
||||
return siblings == 0
|
||||
? null
|
||||
: $"이 페이지에 라디오 버튼 {picked + siblings}개 중 {picked}개만 묶습니다.\n"
|
||||
+ $"남는 {siblings}개는 다른 선택 묶음이 됩니다. 계속할까요?";
|
||||
}
|
||||
|
||||
private PageViewModel? PageOf2(int index) => index >= 0 && index < Pages.Count ? Pages[index] : null;
|
||||
|
||||
/// <summary>
|
||||
@@ -2122,6 +2660,19 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
=> total.TryGetValue(id, out var t) && t > 0
|
||||
&& selected.TryGetValue(id, out var s) && s == t;
|
||||
|
||||
// 조상 중에 '선택된 컨테이너'가 있는가 — 선택한 패널의 <b>범위</b>를 목록에 그리기 위한 판정
|
||||
static bool HasSelectedContainerAncestor(ControlViewModel viewModel)
|
||||
{
|
||||
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
|
||||
{
|
||||
if (parent.IsSelected)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var states = new LayerHighlight[LayerRows.Count];
|
||||
for (var i = 0; i < LayerRows.Count; i++)
|
||||
{
|
||||
@@ -2131,6 +2682,12 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
ControlViewModel c when c.Model.GroupId is { } gid && GroupFullySelected(gid) => LayerHighlight.Navy,
|
||||
ControlViewModel c when c.IsSelected => LayerHighlight.Blue,
|
||||
LayerGroupViewModel g when GroupFullySelected(g.GroupId) => LayerHighlight.Blue,
|
||||
// 선택한 컨테이너 안에 있는 것 — 그룹 판정보다 <b>뒤에</b> 둔다(그룹 색을 이기면 안 된다).
|
||||
//
|
||||
// 캔버스에서 패널을 끌면 자식이 전부 따라 움직이는데 목록은 그 사실을 말하지 않았다.
|
||||
// 밴드가 붙으면 "이걸 지우면 무엇이 같이 지워지나"가 선택 즉시 보인다 —
|
||||
// 접었다 펴 보지 않아도 된다.
|
||||
ControlViewModel c when HasSelectedContainerAncestor(c) => LayerHighlight.Navy,
|
||||
_ => LayerHighlight.None,
|
||||
};
|
||||
}
|
||||
@@ -2223,6 +2780,46 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
|
||||
/// <summary>페이지 펼침 상태 — 기본은 펼침. 키를 인덱스가 아니라 <b>페이지 인스턴스</b>로 잡는다
|
||||
/// (인덱스로 잡으면 페이지를 중간에 끼우거나 지웠을 때 엉뚱한 페이지가 접힌다)</summary>
|
||||
/// <summary>
|
||||
/// 이 컨트롤의 행이 목록에 <b>나타나도록</b> 조상을 전부 편다 — 실제로 편 것이 있으면 true.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 접기는 레이어 목록만의 개념이라 캔버스에는 자식이 그대로 보인다.
|
||||
/// 그래서 종이 한가운데 컨트롤을 클릭했는데 그 조상이 접혀 있으면
|
||||
/// 목록에는 <b>행 자체가 없어</b> 아무 반응이 없다 — 사용자 눈에는 선택이 안 된 것으로 보이고,
|
||||
/// 캔버스와 목록 중 어느 쪽이 맞는지 판단할 단서를 잃는다.
|
||||
/// 접기·검색·그룹을 쓸수록 어긋나므로, 기존 기능이 오히려 신뢰를 깎고 있었다.
|
||||
///
|
||||
/// 반대 방향(목록 더블클릭 → 캔버스로 스크롤)은 <see cref="RevealControl"/> 로 이미 있다 —
|
||||
/// 이것은 <b>그 역방향을 잇는 일</b>이지 새 기능이 아니다.
|
||||
/// </summary>
|
||||
public bool ExpandLayerPathTo(ControlViewModel viewModel)
|
||||
{
|
||||
var changed = false;
|
||||
|
||||
// 페이지 마디 — 공개 메서드를 쓴다(목록 재구성 요청까지 함께 도는 경로다)
|
||||
if (PageOf(viewModel) is { } page && !IsPageExpanded(page))
|
||||
{
|
||||
SetPageExpanded(page, true);
|
||||
changed = true;
|
||||
}
|
||||
// 그룹 폴더 — 그룹 멤버는 폴더가 접히면 행이 안 만들어진다
|
||||
if (viewModel.Model.GroupId is { } groupId && !IsGroupExpanded(groupId))
|
||||
{
|
||||
SetGroupExpanded(groupId, true);
|
||||
changed = true;
|
||||
}
|
||||
// 컨테이너 조상 — 자기 자신은 펴지 않는다(자기 행은 이미 있다)
|
||||
for (var parent = viewModel.Parent; parent is not null; parent = parent.Parent)
|
||||
{
|
||||
if (!parent.IsLayerExpanded)
|
||||
{
|
||||
parent.IsLayerExpanded = true;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
return changed;
|
||||
}
|
||||
|
||||
public bool IsPageExpanded(PageViewModel page) => !collapsedPages.Contains(page);
|
||||
|
||||
/// <summary>페이지 펼침/접기</summary>
|
||||
@@ -2322,6 +2919,69 @@ public sealed class DesignerViewModel : ViewModelBase
|
||||
/// 간격 조절 — 가로/세로 균등·넓게·좁게·붙이기 (레거시 TK_Horiz/VertSpace 계열).
|
||||
/// 축 정렬 순서로 첫 컨트롤 고정, 이후 컨트롤 이동. 넓게/좁게 단위는 그리드 크기.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 선택한 것들 사이 간격을 <b>정확한 픽셀 값으로</b> 놓는다.
|
||||
///
|
||||
/// <b>왜 필요한가.</b> 여기까지 간격을 조절하는 길은 4px 씩 넓게/좁게뿐이었다.
|
||||
/// 20px 를 32px 로 바꾸려면 3번, 로고 아래 4단 메뉴로 하면 <b>12왕복</b>이었다.
|
||||
/// 단축키(Alt+[ / Alt+])를 붙여 왕복은 없앴지만 여전히 4px 씩이다.
|
||||
/// 드래그 스냅은 형제 간격을 정확히 계산해 분홍 표식까지 그리는데(SnapSolver),
|
||||
/// 명령 경로에는 그 값을 <b>넣을 칸</b>이 없었다.
|
||||
///
|
||||
/// 첫 항목은 움직이지 않는다 — 기준이 흔들리면 같은 값을 두 번 넣어도 자리가 계속 밀린다.
|
||||
/// </summary>
|
||||
public void SetExactSpacing(double gap, bool horizontal)
|
||||
{
|
||||
if (Selection.Items.Count < 2 || !double.IsFinite(gap) || gap < 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var ordered = Selection.Items
|
||||
.Select(vm => (Vm: vm, World: WorldBoundsOf(vm), Offset: ParentWorldOffset(vm)))
|
||||
.OrderBy(x => horizontal ? x.World.X : x.World.Y)
|
||||
.ToList();
|
||||
|
||||
Undo.Snapshot();
|
||||
var cursor = horizontal ? ordered[0].World.Right : ordered[0].World.Bottom;
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
var (vm, world, offset) = ordered[i];
|
||||
if (horizontal)
|
||||
{
|
||||
vm.X = Math.Round(cursor + gap - offset.X);
|
||||
cursor += gap + world.Width;
|
||||
continue;
|
||||
}
|
||||
vm.Y = Math.Round(cursor + gap - offset.Y);
|
||||
cursor += gap + world.Height;
|
||||
}
|
||||
Selection.NotifyBoundsChanged();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 지금 선택의 간격 — 숫자 칸에 무엇을 보여 줄지.
|
||||
/// 값이 서로 다르면 null(빈 칸) — 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
|
||||
/// </summary>
|
||||
public double? CurrentSpacing(bool horizontal)
|
||||
{
|
||||
if (Selection.Items.Count < 2)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var ordered = Selection.Items
|
||||
.Select(vm => WorldBoundsOf(vm))
|
||||
.OrderBy(r => horizontal ? r.X : r.Y)
|
||||
.ToList();
|
||||
var gaps = new List<double>();
|
||||
for (var i = 1; i < ordered.Count; i++)
|
||||
{
|
||||
gaps.Add(Math.Round(horizontal
|
||||
? ordered[i].X - ordered[i - 1].Right
|
||||
: ordered[i].Y - ordered[i - 1].Bottom));
|
||||
}
|
||||
return gaps.Distinct().Count() == 1 ? gaps[0] : null;
|
||||
}
|
||||
|
||||
public void AdjustSpacing(string mode)
|
||||
{
|
||||
var isHorizontal = mode.StartsWith("Horiz", StringComparison.Ordinal);
|
||||
|
||||
@@ -112,6 +112,46 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
/// <summary>간격 균등 버튼을 보일 것인가 — 사이가 있어야 나눌 것이 있다(3개 이상)</summary>
|
||||
public bool CanDistribute => designer.CanDistribute();
|
||||
|
||||
/// <summary>
|
||||
/// 정확한 간격은 <b>둘</b>부터 뜻이 있다 — 라벨과 입력칸 한 쌍이 가장 흔한 경우다.
|
||||
/// 균등 나누기(<see cref="CanDistribute"/>)가 셋을 요구하는 것과 기준이 다르다.
|
||||
/// </summary>
|
||||
public bool CanSpaceExactly => designer.Selection.Items.Count >= 2;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 세로 간격. 값이 갈리면 빈 칸이다 —
|
||||
/// 하나로 보여 주면 그 값이 이미 적용된 것처럼 읽힌다.
|
||||
/// </summary>
|
||||
public string VerticalGapText
|
||||
{
|
||||
get => designer.CurrentSpacing(horizontal: false) is { } gap
|
||||
? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: string.Empty;
|
||||
set
|
||||
{
|
||||
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var gap))
|
||||
{
|
||||
designer.SetExactSpacing(gap, horizontal: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public string HorizontalGapText
|
||||
{
|
||||
get => designer.CurrentSpacing(horizontal: true) is { } gap
|
||||
? gap.ToString("0", System.Globalization.CultureInfo.InvariantCulture)
|
||||
: string.Empty;
|
||||
set
|
||||
{
|
||||
if (double.TryParse(value, System.Globalization.NumberStyles.Float,
|
||||
System.Globalization.CultureInfo.InvariantCulture, out var gap))
|
||||
{
|
||||
designer.SetExactSpacing(gap, horizontal: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 정렬 실행 — 뷰의 DataContext 가 인스펙터라 디자이너 커맨드에 직접 닿을 수 없어 위임한다.
|
||||
/// </summary>
|
||||
@@ -274,6 +314,9 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
{
|
||||
OnPropertyChanged(nameof(CanAlignVertical));
|
||||
OnPropertyChanged(nameof(CanDistribute));
|
||||
OnPropertyChanged(nameof(CanSpaceExactly));
|
||||
OnPropertyChanged(nameof(VerticalGapText));
|
||||
OnPropertyChanged(nameof(HorizontalGapText));
|
||||
OnPropertyChanged(nameof(AlignScopeText));
|
||||
OnPropertyChanged(nameof(VerticalScopeText));
|
||||
OnPropertyChanged(nameof(AlignSelectionScopeLabel));
|
||||
@@ -298,8 +341,38 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 행이 통째로 다시 만들어지려는 순간 / 다 만들어진 순간.
|
||||
///
|
||||
/// 뷰가 포커스와 스크롤을 되돌려 놓는 데 쓴다. 선택이 바뀌면 <see cref="Rows"/> 를 비우고
|
||||
/// 새로 만들기 때문에, 그대로 두면 <b>컨트롤을 옮길 때마다 편집하던 칸과 스크롤 위치가 사라진다</b>.
|
||||
/// 하루 수백 건을 다루는 사람에게 그건 "다음 것으로 넘어가면 손이 처음으로 돌아간다"는 뜻이다.
|
||||
///
|
||||
/// 행 컬렉션을 키 기준 갱신으로 바꾸는 쪽이 더 근본적이지만, 이 패널은 회귀 기록이 가장 많은 곳이라
|
||||
/// 이번에는 손대지 않는다. 실제 아픔은 재구성 비용이 아니라 <b>포커스·스크롤 소실</b>이고
|
||||
/// (--scale-budget 이 뷰모델 작업은 마이크로초 단위임을 보여 준다) 그것만 되돌려도 손이 이어진다.
|
||||
/// </summary>
|
||||
public event Action? Rebuilding;
|
||||
|
||||
public event Action? Rebuilt;
|
||||
|
||||
/// <summary>행 재구성 — 선택 집합 변경 시</summary>
|
||||
public void Rebuild()
|
||||
{
|
||||
// 감싸는 이유: 본문에 이른 return 이 여러 갈래 있어, 끝에 이벤트를 두면
|
||||
// 도는 경로와 안 도는 경로가 갈린다. 그러면 어떤 선택 변경에서는 포커스가 안 돌아온다.
|
||||
Rebuilding?.Invoke();
|
||||
try
|
||||
{
|
||||
RebuildCore();
|
||||
}
|
||||
finally
|
||||
{
|
||||
Rebuilt?.Invoke();
|
||||
}
|
||||
}
|
||||
|
||||
private void RebuildCore()
|
||||
{
|
||||
Rows.Clear();
|
||||
boundsRows.Clear();
|
||||
@@ -315,7 +388,7 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
var items = designer.Selection.Items;
|
||||
if (items.Count == 0)
|
||||
{
|
||||
RefreshTabCounts(null);
|
||||
RefreshTabCounts(Array.Empty<PropertyDef>(), 0);
|
||||
if (designer.InspectedPage is { } page)
|
||||
{
|
||||
BuildPageRows(page);
|
||||
@@ -324,10 +397,22 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
return;
|
||||
}
|
||||
|
||||
// 같은 타입만 타입 전용 섹션을 낸다 — 탭 개수 계산도 이 조건을 따라야 어긋나지 않는다
|
||||
var type = items[0].Type;
|
||||
var descriptor = items.All(i => i.Type == type) ? ControlRegistry.Find(type) : null;
|
||||
RefreshTabCounts(descriptor);
|
||||
// 편집할 속성 집합을 <b>먼저</b> 정하고, 그 결과로 탭을 정한다.
|
||||
//
|
||||
// 순서가 중요하다. 전에는 "타입이 전부 같은가"만 보고 탭 가시성을 정했는데,
|
||||
// 타입이 섞이면 descriptor 가 null 이라 데이터·동작 탭이 통째로 접혔다.
|
||||
// 그런데 여러 개를 함께 고쳐야 하는 속성(읽기 전용·필수 입력·재조회·데이터 배선)이
|
||||
// 정확히 그 두 탭에 있다 — 공통 섹션을 아무리 잘 만들어도 화면에는 글꼴과 좌표만 남고
|
||||
// 왜 없는지 알려 주는 표시도 없었다.
|
||||
var models = items.Select(i => i.Model).ToList();
|
||||
var types = items.Select(i => i.Type).Distinct(StringComparer.Ordinal).ToList();
|
||||
var descriptor = types.Count == 1 ? ControlRegistry.Find(types[0]) : null;
|
||||
// MergeCommonDefs 는 타입이 하나면 그 타입의 속성을 그대로, 스키마를 모르는 타입이
|
||||
// 섞이면 빈 집합을 돌려준다 — 여기서 갈래를 나눌 필요가 없다.
|
||||
var merged = BatchEditPolicy.MergeCommonDefs(types)
|
||||
.Where(def => BatchEditPolicy.IsBatchable(def.Key, models, viaAdvancedRaw: false, ParentModelOf).Allowed)
|
||||
.ToList();
|
||||
RefreshTabCounts(merged, items.Count);
|
||||
|
||||
if (selectedTab == InspectorTab.Design)
|
||||
{
|
||||
@@ -356,33 +441,46 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
AddFontStyleSegment();
|
||||
}
|
||||
|
||||
// 타입 전용 — 선택 탭에 해당하는 속성만
|
||||
// 타입 전용(또는 타입이 섞였으면 공통) — 선택 탭에 해당하는 속성만
|
||||
var curatedKeys = new HashSet<string>(HandledKeys, StringComparer.Ordinal);
|
||||
if (descriptor is not null)
|
||||
if (merged.Count > 0)
|
||||
{
|
||||
// 큐레이션 키는 탭과 무관하게 전부 모아야 한다 — 고급 목록에서 중복 노출을 막는 용도라
|
||||
// 탭으로 걸러 버리면 다른 탭 속성이 고급에 다시 나온다.
|
||||
foreach (var def in descriptor.Properties)
|
||||
foreach (var def in merged)
|
||||
{
|
||||
curatedKeys.Add(def.Key);
|
||||
}
|
||||
|
||||
var tabDefs = descriptor.Properties.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
|
||||
var tabDefs = merged.Where(d => InspectorTabCatalog.TabOf(d) == selectedTab).ToList();
|
||||
if (tabDefs.Count > 0)
|
||||
{
|
||||
AddSection(descriptor.DisplayName);
|
||||
// 섹션 제목에 한 타입의 이름을 쓰면 안 된다 — 라벨과 텍스트박스를 함께 고른
|
||||
// 선택에 '라벨'이라는 제목이 붙으면 텍스트박스는 안 바뀐다고 읽는다.
|
||||
AddSection(types.Count == 1 && descriptor is not null
|
||||
? descriptor.DisplayName
|
||||
: $"공통 속성 ({types.Count}종 타입)");
|
||||
foreach (var def in tabDefs)
|
||||
{
|
||||
AddDefRow(def, descriptor.Type);
|
||||
// 태그 제안은 타입별로 후보가 갈린다 — 섞였으면 한쪽으로 편향시키지 않는다
|
||||
AddDefRow(def, types.Count == 1 ? types[0] : string.Empty, models);
|
||||
}
|
||||
// 함께 못 바꾸는 것이 있으면 <b>말한다</b>. 조용히 빼면 그 속성이 없다고 읽는다.
|
||||
var excluded = BatchEditPolicy.ExcludedCount(types);
|
||||
if (excluded > 0)
|
||||
{
|
||||
AddPlain(new ReadOnlyRowViewModel("함께 바꿀 수 없음",
|
||||
$"타입이 달라 값의 뜻이 다른 속성 {excluded}개 — 타입별로 따로 고르세요"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 전체 속성(고급) — 단일 선택 시 PropBag 의 나머지 레거시 속성 전부(레거시 PropertyGrid 등가).
|
||||
// 전체 속성(고급) — PropBag 의 나머지 레거시 원문 속성(레거시 PropertyGrid 등가).
|
||||
// 레거시 원문 키는 분류 근거가 없어 어느 탭에도 속하지 않는다 — 탈출구이므로 모든 탭 아래에 둔다.
|
||||
if (items.Count == 1)
|
||||
// 표(Spread)가 섞이면 열지 않는다 — 격자 디자인이 별도 테이블에 있어 원문 편집의 파장을 모른다.
|
||||
if (!items.Any(i => DesignerViewModel.IsSpread(i.Model)))
|
||||
{
|
||||
BuildAdvancedRows(items[0], curatedKeys);
|
||||
BuildAdvancedRows(items, curatedKeys);
|
||||
}
|
||||
|
||||
OnPropertyChanged(nameof(IsTabEmpty));
|
||||
@@ -396,11 +494,13 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
/// (데이터 탭도 6타입에서 빈다). 빈 탭을 남겨 두면 대부분의 컨트롤에서 "여긴 없습니다"만
|
||||
/// 보게 되고, 사용자는 속성 하나를 찾아 빈 탭을 계속 눌러보게 된다 — 탭이 없느니만 못하다.
|
||||
/// </summary>
|
||||
private void RefreshTabCounts(ControlDescriptor? descriptor)
|
||||
private void RefreshTabCounts(IReadOnlyList<PropertyDef> typed, int selectionCount)
|
||||
{
|
||||
var typed = descriptor?.Properties ?? (IReadOnlyList<PropertyDef>)Array.Empty<PropertyDef>();
|
||||
// 디자인 탭에는 공통 5 + 글꼴 6 이 항상 붙는다 → 선택이 있으면 절대 비지 않는다
|
||||
DesignCount = 11 + typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
|
||||
// 디자인 탭에는 공통 + 글꼴이 항상 붙는다 → 선택이 있으면 절대 비지 않는다.
|
||||
// 다중 선택에는 이름 행이 없으므로(이름은 유일해야 한다) 하나 적다 —
|
||||
// 전에는 11 로 굳어 있어 다중 선택에서 실제 행 수와 어긋났다.
|
||||
DesignCount = (selectionCount == 1 ? 11 : 10)
|
||||
+ typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Design);
|
||||
DataCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Data);
|
||||
BehaviorCount = typed.Count(d => InspectorTabCatalog.TabOf(d) == InspectorTab.Behavior);
|
||||
|
||||
@@ -475,7 +575,8 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
|
||||
// 페이지 글꼴은 자식이 상속한다 — 여기를 바꾸면 Font 를 명시하지 않은 컨트롤이 전부 따라온다
|
||||
AddSection("기본 글꼴 (컨트롤이 상속)");
|
||||
AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v);
|
||||
AddPageFontRow(page, "글꼴", f => f.Family, (f, v) => f.Family = v.Length == 0 ? f.Family : v,
|
||||
isFamily: true);
|
||||
AddPageFontRow(page, "크기(pt)",
|
||||
f => f.SizePt.ToString("0.##", CultureInfo.InvariantCulture),
|
||||
(f, v) => f.SizePt = double.TryParse(v, NumberStyles.Number, CultureInfo.InvariantCulture, out var size) && size > 0
|
||||
@@ -573,9 +674,12 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다</summary>
|
||||
/// <summary>
|
||||
/// 페이지 기본 글꼴 — 자식 상속 컨텍스트가 바뀌므로 문서 전체 시각을 다시 해석한다.
|
||||
/// <paramref name="isFamily"/> 면 이름 칸이라 설치 글꼴 목록에서 고를 수 있게 한다.
|
||||
/// </summary>
|
||||
private void AddPageFontRow(PageViewModel page, string label,
|
||||
Func<LegacyFont, string> get, Action<LegacyFont, string> set)
|
||||
Func<LegacyFont, string> get, Action<LegacyFont, string> set, bool isFamily = false)
|
||||
{
|
||||
var root = page.Model.Root;
|
||||
LegacyFont Current()
|
||||
@@ -584,7 +688,8 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
return text is not null ? LegacyFormat.ParseFont(text) : new LegacyFont();
|
||||
}
|
||||
|
||||
AddPageRow(page, new TextRowViewModel(label), () => get(Current()), value =>
|
||||
PropertyRowViewModel row = isFamily ? NewFontFamilyRow(label) : new TextRowViewModel(label);
|
||||
AddPageRow(page, row, () => get(Current()), value =>
|
||||
{
|
||||
var font = Current();
|
||||
set(font, value);
|
||||
@@ -592,12 +697,41 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}, () => designer.RefreshPageInheritance(page));
|
||||
}
|
||||
|
||||
/// <summary>고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가</summary>
|
||||
private void BuildAdvancedRows(ControlViewModel target, HashSet<string> curatedKeys)
|
||||
/// <summary>
|
||||
/// 고급 섹션 구성 — 접이식, raw 문자열 편집 + 속성 추가.
|
||||
///
|
||||
/// <b>다중 선택에서 위험한 곳이 여기다.</b> 전에는 행의 종류·편집 여부·키 목록을 전부
|
||||
/// <c>items[0]</c> 하나로 정하면서 커밋은 선택 <b>전체</b>에 썼다. 그래서 이미지·중첩·참조 값을
|
||||
/// 지키던 읽기 전용 방어가 "처음 클릭한 컨트롤"에만 걸렸고, 클릭 순서에 따라 같은 조작이
|
||||
/// 안전해지거나 파괴적이 됐다. 게다가 <c>GetText</c> 는 문자열이 아닌 값에 null 을 돌려주므로
|
||||
/// 빈 값과 이미지 원문이 둘 다 "" 로 접혀 <b>갈렸다는 표시조차 나오지 않았다</b>.
|
||||
///
|
||||
/// 그래서 판정을 대상 <b>전체</b>로 바꾼다 — 전부 갖고 있고 전부 문자열일 때만 편집을 연다.
|
||||
/// 레거시 PropertyGrid 의 병합 규칙(이름과 타입이 모든 대상에 공통일 때만 행이 생긴다)과 같은 결이다.
|
||||
/// </summary>
|
||||
private void BuildAdvancedRows(IReadOnlyList<ControlViewModel> targets, HashSet<string> curatedKeys)
|
||||
{
|
||||
var advancedKeys = target.Model.Props.Keys
|
||||
.Where(k => !curatedKeys.Contains(k))
|
||||
.ToList();
|
||||
if (targets.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var target = targets[0];
|
||||
var models = targets.Select(t => t.Model).ToList();
|
||||
var single = targets.Count == 1;
|
||||
// 키 목록은 <b>합집합</b>이다(선언 순서 보존) — 대상마다 가진 키가 다를 수 있고,
|
||||
// 첫 컨트롤만 훑으면 나머지가 가진 속성은 존재조차 보이지 않는다.
|
||||
var advancedKeys = new List<string>();
|
||||
var seenKeys = new HashSet<string>(StringComparer.Ordinal);
|
||||
foreach (var model in models)
|
||||
{
|
||||
foreach (var key in model.Props.Keys)
|
||||
{
|
||||
if (!curatedKeys.Contains(key) && seenKeys.Add(key))
|
||||
{
|
||||
advancedKeys.Add(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!showAdvanced)
|
||||
{
|
||||
@@ -619,6 +753,36 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
|
||||
foreach (var key in advancedKeys)
|
||||
{
|
||||
// 다중 선택은 대상 <b>전체</b>를 보고 판정한다 — 셋 다 통과해야 편집을 연다.
|
||||
if (!single)
|
||||
{
|
||||
if (BatchEditPolicy.IsBatchable(key, models, viaAdvancedRaw: true, ParentModelOf) is { Allowed: false } verdict)
|
||||
{
|
||||
AddPlain(new ReadOnlyRowViewModel(key, $"(함께 바꿀 수 없음 — {verdict.Reason})"));
|
||||
continue;
|
||||
}
|
||||
var having = BatchEditPolicy.CountHaving(key, models);
|
||||
if (having < models.Count)
|
||||
{
|
||||
// 없던 대상에 키를 만들면 레거시 서식생성기가 열 때마다 '이 타입에 없는 속성'
|
||||
// 이라며 모달을 띄운다 — 만들지 않는다
|
||||
AddPlain(new ReadOnlyRowViewModel(key,
|
||||
$"({models.Count}개 중 {having}개에만 있음 — 함께 바꿀 수 없음)"));
|
||||
continue;
|
||||
}
|
||||
if (!BatchEditPolicy.AllShareTextShape(key, models))
|
||||
{
|
||||
AddPlain(new ReadOnlyRowViewModel(key, "(대상마다 값의 형태가 다릅니다 — 함께 바꿀 수 없음)"));
|
||||
continue;
|
||||
}
|
||||
AddRow(new TextRowViewModel(key), new RowBinding
|
||||
{
|
||||
Get = vm => vm.Model.Props.GetText(key) ?? string.Empty,
|
||||
Set = (vm, newValue) => vm.Model.Props.SetText(key, newValue),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
var value = target.Model.Props.Get(key);
|
||||
switch (value)
|
||||
{
|
||||
@@ -663,6 +827,13 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
// 속성 추가는 단일 선택만. 중복 검사·쓰기·제안이 전부 대상 <b>하나</b> 기준이라
|
||||
// 다중 선택에서 누르면 N개 중 1개에만 키가 생기고, 나머지는 아무 표시 없이 그대로다.
|
||||
if (!single)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
// 속성 추가 — 타입별 실사용 키를 제안한다(자유 입력도 그대로 열어 둔다).
|
||||
// 제안이 없으면 사용자가 철자를 외워 맞혀야 하고, 틀려도 경고가 없다 —
|
||||
// 라벨의 소문자 visible, PrintOutPut 의 대문자 P 같은 함정이 실재한다.
|
||||
@@ -679,7 +850,16 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}, LegacyPropertyCatalog.SuggestFor(target.Type, target.Model.Props.Keys)));
|
||||
}
|
||||
|
||||
private void AddDefRow(PropertyDef def, string controlType)
|
||||
/// <summary>
|
||||
/// 컨트롤 모델 → 직계 부모 모델(최상위면 null).
|
||||
///
|
||||
/// 라디오 배타 판정에 쓴다 — 레거시 라디오는 <c>GroupName</c> 이 없어 <b>직계 부모가 곧 문항</b>이다.
|
||||
/// 선택 목록을 뒤져 같은 모델의 VM 을 찾고 그 <c>Parent</c> 를 돌려준다.
|
||||
/// </summary>
|
||||
private ControlElement? ParentModelOf(ControlElement model)
|
||||
=> designer.Selection.Items.FirstOrDefault(vm => ReferenceEquals(vm.Model, model))?.Parent?.Model;
|
||||
|
||||
private void AddDefRow(PropertyDef def, string controlType, IReadOnlyList<ControlElement> targets)
|
||||
{
|
||||
var binding = def.Editor == PropEditorKind.StringList
|
||||
? new RowBinding
|
||||
@@ -696,6 +876,7 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
}
|
||||
vm.Model.Props.Set(def.Key, itemsValue);
|
||||
},
|
||||
ConfirmBatch = ConfirmDestructive(def.Key),
|
||||
}
|
||||
: new RowBinding
|
||||
{
|
||||
@@ -720,6 +901,7 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
vm.Model.Props.SetText(def.Key, value);
|
||||
}
|
||||
},
|
||||
ConfirmBatch = ConfirmDestructive(def.Key),
|
||||
};
|
||||
|
||||
PropertyRowViewModel row = def.Editor switch
|
||||
@@ -736,24 +918,109 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
PropEditorKind.DataInterfaceTag => new TagPickerRowViewModel(def.Label,
|
||||
"데이터 태그 선택 — 자동 채움 원천(bzDataInterface)", LegacyTagCatalog.DataInterfaceTags,
|
||||
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
|
||||
// 액션은 값이 아니라 동작이다 — 피커가 데이터 태그용 값·사유 칸을 그리면 안 된다
|
||||
PropEditorKind.DataActionTag => new TagPickerRowViewModel(def.Label,
|
||||
"액션 태그 선택 — 더블클릭/버튼 액션(EN_DataActionTyp)", LegacyTagCatalog.DataActionTags,
|
||||
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)),
|
||||
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label),
|
||||
LegacyTagUsageCatalog.SuggestFor(def.Key, controlType)) { IsActionTag = true },
|
||||
// 컨트롤 이름을 넘긴다 — 안 넘기면 인스펙터로 연 편집기에서만 《컨트롤명》 칩이 사라져
|
||||
// 같은 창이 진입 경로에 따라 능력이 달라진다
|
||||
PropEditorKind.SqlQuery => new QueryRowViewModel(def.Label) { ControlNamesOf = ControlNames },
|
||||
PropEditorKind.Mask => new MaskRowViewModel(def.Label),
|
||||
// 배선 대상 목록은 '지금 이 서식'의 데이터소스다 — 런타임도 같은 서식 안에서만 이름을 찾는다
|
||||
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames),
|
||||
PropEditorKind.DataTableField => new DataTableFieldRowViewModel(def.Label, DataTableNames)
|
||||
{
|
||||
// 컬럼 목록과 대상 컨트롤 이름도 함께 준다 — 창이 사용자를 밖으로 내보내지 않게 한다
|
||||
OwnerLabelOf = () => designer.Selection.Primary?.Id ?? string.Empty,
|
||||
ColumnsOf = DataSourceColumns,
|
||||
},
|
||||
_ => new TextRowViewModel(def.Label),
|
||||
};
|
||||
AddRow(row, binding);
|
||||
// 선의 방향·굵기는 값만 바꾸는 것이 아니라 <b>크기까지</b> 바꾼다(ApplyLineChange).
|
||||
// 알리지 않으면 선택 핸들이 옛 사각형에 남아, 화면에서는 선이 돌았는데 테두리는 그대로다.
|
||||
AddRow(row, binding, def.Key is "Orientation" or "BorderWidth"
|
||||
? () => designer.Selection.NotifyBoundsChanged()
|
||||
: null);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 여러 대상의 <b>고유값을 한꺼번에 덮는</b> 편집에만 붙이는 확인 — 텍스트·항목 목록.
|
||||
///
|
||||
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다. 확인을 남발하면 아무도 읽지 않고,
|
||||
/// 정작 위험한 것을 물었을 때도 습관적으로 넘긴다.
|
||||
/// </summary>
|
||||
private static Func<int, bool>? ConfirmDestructive(string key) => key is "Text" or "Items"
|
||||
? count => Services.DialogService.Confirm("일괄 변경",
|
||||
$"{count}개 컨트롤의 기존 값이 모두 같은 값으로 바뀝니다.",
|
||||
"각자 다르게 들어 있던 값은 되살릴 수 없습니다(되돌리기로만 복구됩니다).")
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// 이 서식의 데이터소스(MDataTable) 이름 — 배선 편집기의 선택지.
|
||||
///
|
||||
/// 레거시 편집기는 열려 있는 <b>모든</b> 디자인 서피스를 훑어 다른 서식의 데이터소스까지 섞어 보여 주고,
|
||||
/// 그걸 고르면 런타임에 조용히 실패한다(bzMDataTableField.vb:18-63). 우리는 현재 문서로 한정한다.
|
||||
/// </summary>
|
||||
/// <summary>
|
||||
/// 이 서식의 모든 컨트롤 이름 — 쿼리 편집기의 《컨트롤명》 치환 후보다.
|
||||
///
|
||||
/// 데이터소스 아이콘에서 편집기를 열면 이 목록이 넘어가는데 인스펙터의 쿼리 행에서는
|
||||
/// 안 넘어가서, 같은 창이 진입 경로에 따라 능력이 달랐다.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string> ControlNames()
|
||||
{
|
||||
var names = new List<string>();
|
||||
void Visit(SheetMe.Core.Models.ControlElement element)
|
||||
{
|
||||
if (element.Id.Length > 0)
|
||||
{
|
||||
names.Add(element.Id);
|
||||
}
|
||||
foreach (var child in element.Children)
|
||||
{
|
||||
Visit(child);
|
||||
}
|
||||
}
|
||||
foreach (var page in designer.Document.Pages)
|
||||
{
|
||||
foreach (var control in page.Controls)
|
||||
{
|
||||
Visit(control);
|
||||
}
|
||||
}
|
||||
names.Sort(StringComparer.Ordinal);
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스의 조회 결과 컬럼 이름 — 못 읽으면 null(미접속·쿼리 실패).
|
||||
///
|
||||
/// 값 가져오기 창이 컬럼을 목록으로 보여 줄 수 있게 하는 재료다. 전에는 그 창이
|
||||
/// "컬럼 후보는 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 밖으로 보냈는데,
|
||||
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
|
||||
///
|
||||
/// 실행은 <see cref="Services.MDataTableRunner"/> 가 한다 — 접속·치환·캐시·실패 처리가
|
||||
/// 이미 그 한 군데에 있고, 여기서 다시 만들면 두 벌이 된다.
|
||||
/// 러너가 데이터소스별로 결과를 캐시하므로 창이 여러 번 물어도 조회는 한 번이다.
|
||||
/// </summary>
|
||||
private IReadOnlyList<string>? DataSourceColumns(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
var patient = Services.PatientSession.Current;
|
||||
var runner = new Services.MDataTableRunner(designer.Document,
|
||||
patient is null ? null
|
||||
: new Services.PatientQueryVariableSource(patient, Services.UserSession.Current.UidCod));
|
||||
var result = runner.Run(name);
|
||||
return result.Ok && result.Columns.Count > 0 ? result.Columns : null;
|
||||
}
|
||||
catch
|
||||
{
|
||||
// 컬럼 목록은 <b>도움</b>이다 — 못 읽는다고 창이 안 열려서는 안 된다.
|
||||
// 창은 null 을 받으면 이유를 적고 직접 입력 칸을 남긴다.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> DataTableNames()
|
||||
{
|
||||
var names = new List<string>();
|
||||
@@ -832,6 +1099,8 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
set(vm, Math.Round(number));
|
||||
}
|
||||
},
|
||||
// 숫자가 아니면 커밋 자체를 막는다 — 전에는 스냅샷만 쌓이고 값은 하나도 안 바뀌었다
|
||||
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out _),
|
||||
AffectsVisual = false,
|
||||
};
|
||||
BindRow(row, binding, afterCommit: () => designer.Selection.NotifyBoundsChanged());
|
||||
@@ -839,9 +1108,16 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
return row;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 글꼴 이름 행 — 설치된 글꼴에서 고른다(직접 입력도 그대로 된다).
|
||||
///
|
||||
/// 전에는 자유 입력 칸 하나뿐이라 "돋움"을 매번 타이핑해야 했다. 그리고 한 글자만 틀려도
|
||||
/// 레거시는 아무 말 없이 기본 글꼴로 떨어진다 — 디자이너에서는 멀쩡해 보이고
|
||||
/// EMR 에서만 다른 글꼴로 나온다.
|
||||
/// </summary>
|
||||
private void AddFontRow(string label, Func<LegacyFont, string> get, Action<LegacyFont, string> set)
|
||||
{
|
||||
AddRow(new TextRowViewModel(label), new RowBinding
|
||||
AddRow(NewFontFamilyRow(label), new RowBinding
|
||||
{
|
||||
Get = vm => get(vm.EffectiveFont),
|
||||
Set = (vm, value) =>
|
||||
@@ -868,6 +1144,10 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
set(font, value);
|
||||
vm.Model.Props.SetText("Font", LegacyFormat.FormatFont(font));
|
||||
},
|
||||
// 글꼴 크기는 양수만 — 0 이나 음수를 넣으면 Set 이 조용히 옛 크기를 유지해
|
||||
// "바꿨는데 안 바뀐다"가 된다. 커밋 전에 막고 칸을 되돌린다.
|
||||
Validate = text => double.TryParse(text, NumberStyles.Number, CultureInfo.InvariantCulture, out var size)
|
||||
&& size > 0,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -902,6 +1182,16 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
return new SegmentItem(icon, label, toggle);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 글꼴 이름 행 생성 — 컨트롤 글꼴과 페이지 기본 글꼴이 같은 것을 쓴다.
|
||||
///
|
||||
/// 목록은 <b>한글 이름</b>이다. 레거시가 저장하는 값이 그것이라("굴림, 11.25pt")
|
||||
/// 영문 이름을 쓰면 고르는 순간 서식의 글꼴 이름이 바뀌고, 레거시가 그것을 못 찾으면
|
||||
/// 조용히 기본 글꼴로 떨어진다.
|
||||
/// </summary>
|
||||
private static FontFamilyRowViewModel NewFontFamilyRow(string label)
|
||||
=> new(label, SheetMe.Designer.Controls.InstalledFontCatalog.Families(), SheetMe.Designer.Controls.InstalledFontCatalog.IsInstalled);
|
||||
|
||||
/// <summary>편집 기준 폰트 — 명시 Font 있으면 그 값, 없으면 유효(상속) 폰트 복사본을 물화</summary>
|
||||
private static LegacyFont CurrentFontOf(ControlViewModel vm)
|
||||
{
|
||||
@@ -957,6 +1247,12 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
// 목록 밖 값이 들어오면 ComboBox 가 null 로 코어스해 되쓰면서 속성을 지운다 — 표시 전에 편입시킨다.
|
||||
if (row is ChoiceRowViewModel choice)
|
||||
{
|
||||
// 값이 갈리면 '여러 값' 자리를 먼저 만든다 — 그러면 빈 칸이 '값 없음'과 구별되고,
|
||||
// 목록에 없는 값(빈 문자열)을 SelectedItem 에 물리는 상태 자체가 사라진다.
|
||||
if (values.Count > 1)
|
||||
{
|
||||
choice.EnsureMixedPlaceholder();
|
||||
}
|
||||
choice.EnsureChoice(initial);
|
||||
}
|
||||
|
||||
@@ -969,6 +1265,27 @@ public sealed class InspectorViewModel : ViewModelBase
|
||||
{
|
||||
return;
|
||||
}
|
||||
// ── 스냅샷 <b>앞</b>의 관문 셋. 순서가 중요하다: 여기서 걸러야 '바뀐 것이 없는데
|
||||
// 문서가 수정됨' 이 생기지 않고, 그래야 사용자가 그걸 지우려 Ctrl+Z 를 누를 일도 없다.
|
||||
if (binding.Validate is { } validate && !validate(value))
|
||||
{
|
||||
// 값으로 인정하지 않았으므로 칸을 원래 표시로 되돌린다 — 잘못된 문자열이
|
||||
// 다음 재구성까지 남아 값인 척하는 것을 막는다
|
||||
var current = designer.Selection.Items.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
row.Initialize(current.Count == 1 ? current[0] : null, isMixed: current.Count > 1);
|
||||
return;
|
||||
}
|
||||
// 이미 전부 그 값이면 아무 일도 하지 않는다(스냅샷도 찍지 않는다)
|
||||
var before = targets.Select(binding.Get).Distinct(StringComparer.Ordinal).ToList();
|
||||
if (before.Count == 1 && string.Equals(before[0], value, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (targets.Count > 1 && binding.ConfirmBatch is { } confirm && !confirm(targets.Count))
|
||||
{
|
||||
row.Initialize(before.Count == 1 ? before[0] : null, isMixed: before.Count > 1);
|
||||
return;
|
||||
}
|
||||
// 화살표 연타는 400ms 코얼레스로 한 스텝에 묶는다 — 커밋 1회가 문서 딥클론 1회라
|
||||
// 그대로 두면 ↑ 스무 번에 스냅샷 스무 개가 쌓여 편집 이력이 밀려 나간다.
|
||||
if (row.CoalesceUndo)
|
||||
|
||||
@@ -30,6 +30,15 @@ public abstract class PropertyRowViewModel : ViewModelBase
|
||||
/// <summary>선택 대상들의 값이 서로 다른지 — "여러 값" 표시</summary>
|
||||
public bool IsMixed { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 값이 갈렸을 때 <b>요약 칸</b>에 쓰는 글귀 — 쿼리·마스크·배선처럼 값 대신 요약을 그리는 행용.
|
||||
///
|
||||
/// 이 행들은 빈 칸이 아니라 "(쿼리 없음)"·"(마스크 없음)"·"(배선 없음)" 같은 <b>단정</b>을 그린다.
|
||||
/// 서로 다른 쿼리를 든 컨트롤 둘을 골랐을 때 "쿼리 없음"이 나오면 그건 빈 칸보다 나쁘다 —
|
||||
/// 없다고 믿고 새로 쓰면 양쪽의 원래 쿼리가 한꺼번에 사라진다.
|
||||
/// </summary>
|
||||
protected const string MixedSummary = "(여러 값)";
|
||||
|
||||
/// <summary>
|
||||
/// 이 행이 품고 있는 '속성' 개수 — 섹션 헤더의 건수 표시용.
|
||||
/// 쌍 행·세그먼트 행은 한 줄이지만 속성은 여럿이라, 줄 수를 세면 헤더가 "공통 3"처럼
|
||||
@@ -52,6 +61,11 @@ public abstract class PropertyRowViewModel : ViewModelBase
|
||||
get => valueText;
|
||||
set
|
||||
{
|
||||
// null 을 문자열로 정규화한다. 뷰가 null 을 되밀 수 있는 경로가 있고
|
||||
// (ComboBox.SelectedItem 은 TwoWay 기본이라 목록에 없는 값을 null 로 코어스한다)
|
||||
// 그 null 이 커밋까지 흘러가면 AddDefRow 의 value.Length 에서 NullReferenceException 이다.
|
||||
// 아래 SetProperty 도 "" 와 null 을 다른 값으로 보아 무의미한 커밋을 한 번 내보낸다.
|
||||
value ??= string.Empty;
|
||||
if (!SetProperty(ref valueText, value) || building)
|
||||
{
|
||||
return;
|
||||
@@ -365,10 +379,21 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
|
||||
get => IsMixed ? null : ValueText == "True";
|
||||
set
|
||||
{
|
||||
if (value is bool on)
|
||||
if (value is not bool on)
|
||||
{
|
||||
ValueText = on ? "True" : "False";
|
||||
return;
|
||||
}
|
||||
// <b>갈린 상태에서 누르면 켠다.</b> WPF ToggleButton.OnToggle 은 IsChecked 가 null 이면
|
||||
// IsThreeState 와 무관하게 false 로 간다(내부적으로 isChecked.HasValue — null 이면 false).
|
||||
// 그대로 두면 굵게를 <b>켜려고</b> 누른 한 번이 선택 전체를 '굵게 끔'으로 커밋한다.
|
||||
// 실측(edit-smoke): 누르기 전 IsChecked=null → 커밋 "False" → 누른 뒤 False.
|
||||
// 갈린 상태에서 setter 에 false 가 오는 경로는 이 클릭뿐이므로(사용자가 '꺼짐'을 본 적이
|
||||
// 없다) 켜는 것으로 읽는다 — 디자이너 관례도 '혼합을 누르면 전부 켜기'다.
|
||||
if (IsMixed && !on)
|
||||
{
|
||||
on = true;
|
||||
}
|
||||
ValueText = on ? "True" : "False";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -377,6 +402,47 @@ public sealed class ToggleRowViewModel : PropertyRowViewModel
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(IsOn));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 글꼴 이름 행 — 설치된 글꼴 목록에서 고르거나 직접 입력한다.
|
||||
///
|
||||
/// <b>왜 자유 입력만으로는 안 되나.</b> 전에는 그냥 텍스트 칸이라 "돋움"을 매번 타이핑해야 했고,
|
||||
/// 한 글자만 틀려도 조용히 기본 글꼴로 떨어졌다(레거시는 못 찾은 이름에 대해 아무 말도 하지 않는다).
|
||||
///
|
||||
/// <b>왜 목록만으로도 안 되나.</b> 병원 PC 와 이 PC 의 설치 글꼴이 다르다. 목록에 가두면
|
||||
/// 여기 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고, 손대는 순간 사라진다.
|
||||
/// 그래서 <b>고르기도 되고 쓰기도 되는</b> 칸이다.
|
||||
/// </summary>
|
||||
public sealed class FontFamilyRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
private readonly Func<string, bool> isInstalled;
|
||||
|
||||
/// <summary>선택지 — 이 PC 에 설치된 글꼴</summary>
|
||||
public IReadOnlyList<string> Families { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 지금 값이 이 PC 에 없는 글꼴인가 — 그러면 <b>이 화면이 병원과 다르게 보인다</b>.
|
||||
/// 서식이 틀린 것이 아니므로 값은 건드리지 않고 알리기만 한다.
|
||||
/// </summary>
|
||||
public bool IsMissingFont => !IsMixed && ValueText.Length > 0 && !isInstalled(ValueText);
|
||||
|
||||
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
|
||||
public string MissingHint => $"'{ValueText}' 은(는) 이 PC 에 설치되어 있지 않습니다. "
|
||||
+ "저장되는 값은 그대로지만, 화면에는 대체 글꼴로 그려집니다.";
|
||||
|
||||
public FontFamilyRowViewModel(string label, IReadOnlyList<string> families, Func<string, bool> isInstalled)
|
||||
: base(label)
|
||||
{
|
||||
Families = families;
|
||||
this.isInstalled = isInstalled;
|
||||
}
|
||||
|
||||
protected override void OnValueApplied()
|
||||
{
|
||||
OnPropertyChanged(nameof(IsMissingFont));
|
||||
OnPropertyChanged(nameof(MissingHint));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>선택지 행</summary>
|
||||
public sealed class ChoiceRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
@@ -402,6 +468,27 @@ public sealed class ChoiceRowViewModel : PropertyRowViewModel
|
||||
choices.Add(value);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 값이 갈린 다중 선택에서 <b>'여러 값' 자리를 목록 맨 앞에 만든다</b>(빈 문자열).
|
||||
///
|
||||
/// 두 가지를 동시에 해결한다.
|
||||
/// ① <b>보이게 한다.</b> 갈린 값은 ValueText 가 "" 라 콤보가 빈 칸으로 보이는데,
|
||||
/// 그건 '값 없음'과 구별되지 않는다. 목록에 자리가 있으면 "여러 값"으로 그릴 수 있다.
|
||||
/// ② <b>코어스를 막는다.</b> <see cref="EnsureChoice"/> 는 IsNullOrEmpty 에서 빠져나가므로
|
||||
/// "" 를 편입하지 않는다. 목록에 없는 값을 SelectedItem 에 물리면 WPF Selector 가
|
||||
/// null 로 코어스하고 TwoWay 로 되쓸 수 있다 — 그 경로는 실측(edit-smoke)에서 재현되지
|
||||
/// 않았지만, 자리를 만들어 두면 코어스 자체가 성립하지 않으므로 따질 일이 없어진다.
|
||||
///
|
||||
/// 사용자가 이 빈 항목을 다시 골라도 ""→"" 라 SetProperty 가 false 를 돌려주고 커밋이 없다.
|
||||
/// </summary>
|
||||
public void EnsureMixedPlaceholder()
|
||||
{
|
||||
if (!choices.Contains(string.Empty, StringComparer.Ordinal))
|
||||
{
|
||||
choices.Insert(0, string.Empty);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -416,6 +503,29 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
/// <summary>피커 대화상자 제목</summary>
|
||||
public string PickerTitle { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 액션 태그 행인가 — 액션은 <b>값이 아니라 동작</b>이라 피커의 값·사유 칸 뜻이 다르다.
|
||||
/// 데이터 태그용 문구를 그대로 쓰면 149종 전부에서 그 칸이 거짓이 된다.
|
||||
/// </summary>
|
||||
public bool IsActionTag { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 카탈로그에 없는 이름인가 — <b>값은 건드리지 않고</b> 표식만 낸다.
|
||||
///
|
||||
/// 인라인 완성으로 직접 치는 것이 이 행의 주 경로인데 거기에는 검증이 하나도 없었다.
|
||||
/// 'PAT_이룸' 처럼 한 글자 틀리면 아무 표시 없이 커밋되고, 그 사실은 미리보기에서
|
||||
/// <b>빈칸</b>으로만 드러난다 — 빈칸의 원인이 오타인지 이 환자에게 자료가 없어서인지 가릴 수 없다.
|
||||
///
|
||||
/// 막지는 않는다. 사이트마다 카탈로그에 없는 커스텀 태그를 쓰기 때문이다(글꼴 행과 같은 규칙).
|
||||
/// </summary>
|
||||
public bool IsUnknownTag => !IsMixed && ValueText.Length > 0
|
||||
&& !string.Equals(ValueText, "None", StringComparison.Ordinal)
|
||||
&& !Choices.Contains(ValueText, StringComparer.Ordinal);
|
||||
|
||||
/// <summary>경고 문구 — 툴팁으로 보인다</summary>
|
||||
public string UnknownTagHint => $"'{ValueText}' 은(는) 카탈로그에 없는 이름입니다. "
|
||||
+ "사이트 전용 태그면 그대로 두고, 오타면 고치세요 — 값이 안 나오면 종이에서 빈칸이 됩니다.";
|
||||
|
||||
/// <summary>
|
||||
/// 한 줄에 하나꼴로 접히는 긴 한글 태그를 몇 개까지 깔지.
|
||||
/// 인스펙터 폭이 좁아 칩이 세로로 쌓인다 — 8개를 넘기면 다른 속성이 화면 밖으로 밀린다.
|
||||
@@ -433,11 +543,134 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
public IReadOnlyList<string> Suggestions { get; }
|
||||
|
||||
/// <summary>
|
||||
/// 제안을 보여 줄지 — <b>값이 비어 있을 때만</b>.
|
||||
/// 이미 정해진 행 아래에도 칩을 깔면 좁은 인스펙터가 금방 길어진다.
|
||||
/// 제안이 필요한 순간은 '아직 안 골랐을 때'다.
|
||||
/// 제안을 보여 줄지.
|
||||
///
|
||||
/// 전에는 <c>ValueText.Length == 0</c> 이었다 — 값이 정해지면 칩이 사라졌다.
|
||||
/// 그래서 <b>잘못 고른 태그를 바꿀 때 도움이 끊겼다</b>. 실사용에서 태그를 고치는 일은
|
||||
/// 처음 고르는 일만큼 잦은데(운영 태그 사용 8,032건), 고치는 쪽만 맨손이었다.
|
||||
/// 이제 값이 있어도 남긴다. 대신 <b>타이핑을 시작하면 접는다</b> —
|
||||
/// 그때부터는 아래 완성 목록이 같은 일을 더 정확하게 한다.
|
||||
/// </summary>
|
||||
public bool ShowSuggestions => ValueText.Length == 0 && Suggestions.Count > 0;
|
||||
public bool ShowSuggestions => !IsTyping && Suggestions.Count > 0;
|
||||
|
||||
/// <summary>
|
||||
/// 인라인 완성 목록 — 이 행에서 모달 없이 태그를 고르는 주 경로.
|
||||
///
|
||||
/// <b>왜 여기가 주 경로인가.</b> 칩 상위 8종만으로 운영 사용의 41.7%, 상위 16종으로 61.5% 를 덮는다.
|
||||
/// 나머지는 이름을 정확히 16자 외워 치거나 1080×700 모달을 열어야 했고,
|
||||
/// 자유 입력은 열려 있는데 제안이 없어서 <b>오타가 검증 없이 저장되고
|
||||
/// EMR 에서 조용히 빈 칸이 됐다</b>.
|
||||
/// 같은 인스펙터의 '속성 추가' 행에는 이미 부분일치 제안이 있다 — 패턴은 있는데
|
||||
/// 운영 8,032건이 걸린 칸에만 없었다.
|
||||
/// </summary>
|
||||
public System.Collections.ObjectModel.ObservableCollection<string> Completions { get; } = new();
|
||||
|
||||
/// <summary>완성 목록에서 지금 짚고 있는 줄 — ↑↓ 로 움직이고 Enter 로 확정한다</summary>
|
||||
public int CompletionIndex
|
||||
{
|
||||
get => completionIndex;
|
||||
set => SetProperty(ref completionIndex, value);
|
||||
}
|
||||
|
||||
public bool ShowCompletions
|
||||
{
|
||||
get => showCompletions;
|
||||
private set => SetProperty(ref showCompletions, value);
|
||||
}
|
||||
|
||||
/// <summary>타이핑 중인가 — 칩을 접고 완성을 여는 기준</summary>
|
||||
private bool IsTyping { get; set; }
|
||||
|
||||
private int completionIndex = -1;
|
||||
private bool showCompletions;
|
||||
|
||||
/// <summary>
|
||||
/// 지금 입력된 글자로 완성 목록을 다시 만든다.
|
||||
///
|
||||
/// 순위는 <b>이 타입에서 자주 쓰이는 순서 → 운영 사용 건수 → 이름</b>이다.
|
||||
/// 알파벳 순으로 두면 상위 10종(전체 사용의 47.5%)이 목록 아래로 흩어진다.
|
||||
/// 검색은 <see cref="Core.Catalog.TagSearch"/> 를 그대로 쓴다 — 별칭·초성이 여기서도 듣는다.
|
||||
/// </summary>
|
||||
public void RefreshCompletions(string typed)
|
||||
{
|
||||
// 인자로 받는다 — 태그 행의 Text 바인딩은 UpdateSourceTrigger=LostFocus 라
|
||||
// 타이핑 중에는 ValueText 가 아직 옛 값이다. 그것으로 목록을 만들면 한 글자 뒤처진다.
|
||||
var query = typed.Trim();
|
||||
Completions.Clear();
|
||||
if (!IsTyping || query.Length == 0)
|
||||
{
|
||||
ShowCompletions = false;
|
||||
CompletionIndex = -1;
|
||||
return;
|
||||
}
|
||||
var tokens = query.Split(' ', StringSplitOptions.RemoveEmptyEntries);
|
||||
var ranked = Choices
|
||||
.Where(tag => Core.Catalog.TagSearch.MatchesAll(tag, tokens))
|
||||
.OrderBy(tag =>
|
||||
{
|
||||
var rank = preferredAll.ToList().IndexOf(tag);
|
||||
return rank < 0 ? int.MaxValue : rank;
|
||||
})
|
||||
.ThenByDescending(Core.Catalog.LegacyTagUsageCounts.Of)
|
||||
.ThenBy(tag => tag, StringComparer.Ordinal)
|
||||
.Take(CompletionLimit)
|
||||
.ToList();
|
||||
foreach (var tag in ranked)
|
||||
{
|
||||
Completions.Add(tag);
|
||||
}
|
||||
// 이미 정확히 그 태그만 남았으면 목록을 띄우지 않는다 — 다 골랐는데 가리는 셈이다
|
||||
ShowCompletions = ranked.Count > 0
|
||||
&& !(ranked.Count == 1 && string.Equals(ranked[0], query, StringComparison.Ordinal));
|
||||
CompletionIndex = ShowCompletions ? 0 : -1;
|
||||
}
|
||||
|
||||
/// <summary>사용자가 글자를 넣기 시작했다 — 칩을 접고 완성을 연다</summary>
|
||||
public void BeginTyping()
|
||||
{
|
||||
if (IsTyping)
|
||||
{
|
||||
return;
|
||||
}
|
||||
IsTyping = true;
|
||||
OnPropertyChanged(nameof(ShowSuggestions));
|
||||
}
|
||||
|
||||
/// <summary>편집이 끝났다(확정·취소·포커스 이동) — 칩을 되돌리고 완성을 닫는다</summary>
|
||||
public void EndTyping()
|
||||
{
|
||||
IsTyping = false;
|
||||
ShowCompletions = false;
|
||||
CompletionIndex = -1;
|
||||
Completions.Clear();
|
||||
OnPropertyChanged(nameof(ShowSuggestions));
|
||||
}
|
||||
|
||||
/// <summary>↑↓ — 목록을 벗어나지 않고 순환한다</summary>
|
||||
public void MoveCompletion(int delta)
|
||||
{
|
||||
if (Completions.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
CompletionIndex = ((CompletionIndex + delta) % Completions.Count + Completions.Count) % Completions.Count;
|
||||
}
|
||||
|
||||
/// <summary>짚고 있는 줄을 값으로 확정한다 — 확정했으면 true</summary>
|
||||
public bool CommitCompletion()
|
||||
{
|
||||
if (!ShowCompletions || CompletionIndex < 0 || CompletionIndex >= Completions.Count)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
var picked = Completions[CompletionIndex];
|
||||
EndTyping();
|
||||
ValueText = picked;
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>완성 목록에 깔 줄 수 — 10행×24px 이면 인스펙터를 덮지 않는다</summary>
|
||||
private const int CompletionLimit = 10;
|
||||
|
||||
/// <summary>찾아보기 — 검색 대화상자 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? BrowseCommand { get; set; }
|
||||
@@ -466,13 +699,16 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
if (e.PropertyName == nameof(ValueText))
|
||||
{
|
||||
OnPropertyChanged(nameof(ShowSuggestions));
|
||||
// 값이 바뀌면 '카탈로그에 없음' 표식도 다시 판정한다
|
||||
OnPropertyChanged(nameof(IsUnknownTag));
|
||||
OnPropertyChanged(nameof(UnknownTagHint));
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void OnBrowse()
|
||||
{
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll)
|
||||
var dialog = new Views.TagPickerDialogView(PickerTitle, Choices, ValueText, preferredAll, IsActionTag)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
@@ -490,7 +726,9 @@ public sealed class TagPickerRowViewModel : PropertyRowViewModel
|
||||
public sealed class MaskRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
/// <summary>이 마스크가 화면에 어떻게 보이는지 — 인스펙터에서 바로 확인</summary>
|
||||
public string Preview => ValueText.Length == 0
|
||||
public string Preview => IsMixed
|
||||
? MixedSummary
|
||||
: ValueText.Length == 0
|
||||
? "(마스크 없음)"
|
||||
: SheetMe.Core.Serialization.LegacyMask.ToPromptDisplay(ValueText);
|
||||
|
||||
@@ -525,6 +763,10 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsMixed)
|
||||
{
|
||||
return MixedSummary;
|
||||
}
|
||||
var oneLine = ValueText.Replace("\r", " ").Replace("\n", " ").Trim();
|
||||
return oneLine.Length == 0 ? "(쿼리 없음)" : oneLine.Length > 48 ? oneLine[..48] + "…" : oneLine;
|
||||
}
|
||||
@@ -540,9 +782,20 @@ public sealed class QueryRowViewModel : PropertyRowViewModel
|
||||
|
||||
protected override void OnValueApplied() => OnPropertyChanged(nameof(Summary));
|
||||
|
||||
/// <summary>
|
||||
/// 이 서식의 컨트롤 이름 — 편집기의 《컨트롤명》 치환 후보가 된다.
|
||||
///
|
||||
/// 안 넘기면 <b>같은 창이 진입 경로에 따라 능력이 달라진다</b> —
|
||||
/// 데이터소스 아이콘에서 열면 컨트롤 변수 칩이 나오는데
|
||||
/// 인스펙터의 쿼리 행에서 열면 그 칩이 통째로 사라졌다.
|
||||
/// 목록은 문서마다 다르므로 카탈로그가 아니라 호출부가 준다.
|
||||
/// </summary>
|
||||
public Func<IReadOnlyList<string>>? ControlNamesOf { get; init; }
|
||||
|
||||
private void OnEdit()
|
||||
{
|
||||
var dialog = new Views.QueryEditorWindow(Label, ValueText)
|
||||
var dialog = new Views.QueryEditorWindow(Label, ValueText,
|
||||
showVariables: true, controlNames: ControlNamesOf?.Invoke())
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
@@ -715,26 +968,56 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
|
||||
{
|
||||
private readonly Func<IReadOnlyList<string>> tablesOf;
|
||||
|
||||
/// <summary>사람이 읽는 요약 — 배선이 없으면 안내 문구</summary>
|
||||
/// <summary>
|
||||
/// 사람이 읽는 요약. <b>'배선' 이라는 낱말을 쓰지 않는다</b> —
|
||||
/// 전산실에서 배선은 랜선·전원이고, '배선 없음' 은 데이터소스가 없는 것처럼 읽힌다.
|
||||
/// 빈 상태 문구는 대화상자의 지우기 버튼과 <b>글자까지 같게</b> 둔다 —
|
||||
/// 그 버튼을 누르면 무엇이 되는지 미리 보이게 하려는 것이다.
|
||||
/// </summary>
|
||||
public string Summary
|
||||
{
|
||||
get
|
||||
{
|
||||
if (IsMixed)
|
||||
{
|
||||
return MixedSummary;
|
||||
}
|
||||
if (ValueText.Length == 0)
|
||||
{
|
||||
return "(배선 없음)";
|
||||
return "(가져오지 않음)";
|
||||
}
|
||||
var spec = SheetMe.Core.Catalog.DataTableFieldSpec.Parse(ValueText);
|
||||
return spec is null
|
||||
// 해석 못 하는 값은 원문을 그대로 보여 준다 — 조용히 고쳐 쓰지 않는다
|
||||
? $"⚠ 형식을 알 수 없음: {ValueText}"
|
||||
: $"{spec.TableName} · {spec.Field}" + (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex}행");
|
||||
// 행 0 은 '첫 행' 이다. 그 밖은 사람이 세는 번호(0 → 1번째)로 적는다.
|
||||
// 조건이 있으면 그 사실을 덧붙인다 — 조건이 화면 어디에도 안 보인 채
|
||||
// 결과를 바꾸는 상태를 남기지 않는다.
|
||||
: $"{spec.TableName} · {spec.Field}"
|
||||
+ (spec.RowIndex == 0 ? string.Empty : $" · {spec.RowIndex + 1}번째 행")
|
||||
+ (spec.Form == SheetMe.Core.Catalog.DataTableFieldForm.Select && spec.Filter.Length > 0
|
||||
? " · 조건 있음" : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>편집기 열기</summary>
|
||||
public M.Framework.WPF.ICustomCommand? EditCommand { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// 대상 컨트롤 이름 — 창 제목에 넣는다. 모달이라 인스펙터가 가려져서,
|
||||
/// 여러 칸을 연달아 채울 때 지금 어느 칸 차례인지 알 방법이 없었다.
|
||||
/// </summary>
|
||||
public Func<string>? OwnerLabelOf { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름(없으면 null).
|
||||
///
|
||||
/// 전에는 창이 '컬럼 후보는 목록으로 제시하지 않습니다' 라고 적어 두고 사용자를 밖으로 보냈는데,
|
||||
/// 그 창은 모달이라 밖이 잠겨 있다 — 취소하고 쿼리를 열어 컬럼명을 외워 다시 와야 했다.
|
||||
/// 실행은 이 행이 하지 않는다(호출부가 이미 만들어 둔 러너를 함수로 받는다).
|
||||
/// </summary>
|
||||
public Func<string, IReadOnlyList<string>?>? ColumnsOf { get; init; }
|
||||
|
||||
public DataTableFieldRowViewModel(string label, Func<IReadOnlyList<string>> tablesOf) : base(label)
|
||||
{
|
||||
this.tablesOf = tablesOf;
|
||||
@@ -750,7 +1033,8 @@ public sealed class DataTableFieldRowViewModel : PropertyRowViewModel
|
||||
|
||||
private void OnEdit()
|
||||
{
|
||||
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf())
|
||||
var dialog = new Views.DataTableFieldDialogView(ValueText, tablesOf(),
|
||||
OwnerLabelOf?.Invoke(), ColumnsOf)
|
||||
{
|
||||
Owner = System.Windows.Application.Current.MainWindow,
|
||||
};
|
||||
@@ -864,4 +1148,22 @@ public sealed class RowBinding
|
||||
|
||||
/// <summary>커밋 후 시각 재해석 필요 여부</summary>
|
||||
public bool AffectsVisual { get; init; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// 커밋 전 값 검사 — false 면 <b>아무것도 하지 않고</b> 칸을 원래 표시로 되돌린다.
|
||||
///
|
||||
/// 숫자 칸이 그 예다. 전에는 "100px" 같은 문자열도 스냅샷을 먼저 찍은 뒤 대상마다
|
||||
/// TryParse 에 실패해 아무것도 안 바뀌었다 — <b>바뀐 것이 없는데 문서는 '수정됨'</b>이 되고
|
||||
/// 잘못된 문자열은 다음 재구성까지 칸에 남아 값인 척했다. 다중 선택은 좌표·크기가
|
||||
/// 갈리는 것이 기본에 가까워 이 경로를 훨씬 자주 밟는다.
|
||||
/// </summary>
|
||||
public Func<string, bool>? Validate { get; init; }
|
||||
|
||||
/// <summary>
|
||||
/// 대상이 둘 이상일 때 커밋 전에 받는 확인 — false 면 커밋하지 않는다(인자는 대상 수).
|
||||
///
|
||||
/// 대상마다 <b>서로 다른 고유값</b>이 한 번에 사라지는 편집에만 붙인다(텍스트·항목 목록).
|
||||
/// 좌표·색·인쇄여부처럼 되돌리기 쉬운 것에는 붙이지 않는다 — 확인을 남발하면 아무도 안 읽는다.
|
||||
/// </summary>
|
||||
public Func<int, bool>? ConfirmBatch { get; init; }
|
||||
}
|
||||
|
||||
@@ -168,6 +168,25 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
/// <summary>DB 사용 가능 여부(서식 목록 패널 안내용)</summary>
|
||||
public bool CanUseDb => dataBusiness.CanUseDb;
|
||||
|
||||
/// <summary>
|
||||
/// DB 저장이 가능한 단말인가 — 저장 메뉴·버튼의 활성 조건.
|
||||
///
|
||||
/// 전에는 항상 활성이었고 누른 뒤에야 경고가 떴다. 이제 저장이 DB 로만 가므로
|
||||
/// 못 쓰는 상태를 <b>누르기 전에</b> 보여 줘야 한다.
|
||||
/// SaveMode 기본값이 File 인 데는 이유가 있다 — 배포된 ini 의 CurrentServer 가 운영 병원 DB 를
|
||||
/// 가리켜서, 명시적으로 켠 단말에서만 운영 테이블에 쓰게 하려는 장치다(docs/DEPLOYMENT.md:54-56).
|
||||
/// 그 규칙은 그대로 두고 화면이 상태를 말하게 한다.
|
||||
/// </summary>
|
||||
public bool CanSaveToDb => dataBusiness.CanSaveToDb;
|
||||
|
||||
/// <summary>
|
||||
/// '수정'(제자리 정정)을 이 단말·이 사용자가 쓸 수 있는가.
|
||||
/// 레거시는 이 버튼만 전산실에 묶어 두었다 — 저장 버튼에는 그런 분기가 없다.
|
||||
/// </summary>
|
||||
public bool CanModifyInDb => dataBusiness.CanSaveToDb && FormDesignDataBusiness.CanModify();
|
||||
|
||||
public ICustomCommand? ModifyInDbCommand { get; set; }
|
||||
|
||||
/// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
|
||||
public int SelectedLeftTabIndex
|
||||
{
|
||||
@@ -187,12 +206,23 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
public MainViewModel()
|
||||
{
|
||||
LoadedCommand = new Command(async (sender, e) => await OnLoadedAsync());
|
||||
NewFileCommand = new Command((sender, e) => OnNewFile());
|
||||
OpenFileCommand = new Command((sender, e) => OnOpenFile());
|
||||
SaveFileCommand = new Command((sender, e) => OnSaveFile(saveAs: false));
|
||||
// Ctrl+S 가 이 커맨드에 걸려 있다. 문서 출처로 갈라야 한다 —
|
||||
// DB 에서 연 서식에서 Ctrl+S 를 누르면 OS 저장 대화상자가 뜨고 디스크에 XML 이 쓰였다.
|
||||
// 사용자는 저장했다고 믿지만 <b>DB 에는 아무것도 안 들어간다</b>.
|
||||
// 종료 가드는 이미 IsFromDb 로 갈라 준다(:365) — Ctrl+S 만 안 갈라 주고 있었다.
|
||||
SaveFileCommand = new Command((sender, e) =>
|
||||
{
|
||||
if (CurrentDesigner?.IsFromDb == true)
|
||||
{
|
||||
OnSaveToDb();
|
||||
return;
|
||||
}
|
||||
OnSaveFile(saveAs: false);
|
||||
});
|
||||
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
|
||||
OpenFromDbCommand = new Command((sender, e) => OnOpenFromDb());
|
||||
SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
|
||||
ModifyInDbCommand = new Command((sender, e) => OnModifyInDb());
|
||||
ExportJsonCommand = new Command((sender, e) => OnExportJson());
|
||||
ImportJsonCommand = new Command((sender, e) => OnImportJson());
|
||||
PreviewCommand = new Command((sender, e) => OnPreview());
|
||||
@@ -227,9 +257,6 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
/// <summary>창 로드</summary>
|
||||
public ICustomCommand? LoadedCommand { get; set; }
|
||||
|
||||
/// <summary>새 서식</summary>
|
||||
public ICustomCommand? NewFileCommand { get; set; }
|
||||
|
||||
/// <summary>서식 XML 열기</summary>
|
||||
public ICustomCommand? OpenFileCommand { get; set; }
|
||||
|
||||
@@ -240,7 +267,6 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
public ICustomCommand? SaveAsFileCommand { get; set; }
|
||||
|
||||
/// <summary>DB에서 서식 열기(검색 대화상자)</summary>
|
||||
public ICustomCommand? OpenFromDbCommand { get; set; }
|
||||
|
||||
/// <summary>DB에 저장(E_SdgMst 버저닝 + E_SctMst 재생성) — SaveMode 게이트</summary>
|
||||
public ICustomCommand? SaveToDbCommand { get; set; }
|
||||
@@ -362,9 +388,20 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!(designer.IsFromDb ? OnSaveToDb() : OnSaveFile(saveAs: false)))
|
||||
if (OnSaveToDb())
|
||||
{
|
||||
return false; // 저장 대화상자 취소 또는 오류 — 종료를 중단한다
|
||||
continue;
|
||||
}
|
||||
// 저장이 실패했다 — 여기서 무조건 종료를 막으면 <b>앱을 닫을 수 없는 상태</b>가 된다.
|
||||
// 저장은 DB 로만 가고, SaveMode=File 단말이나 접속 불가 상태에서는 갈 곳이 없기 때문이다.
|
||||
// 전에는 파일 저장 폴백이 있어서 이 분기가 드물었지만 이제는 흔한 경로다.
|
||||
// 그러니 막지 말고 <b>버리고 닫을지</b>를 묻는다 — 사용자가 갇히는 것이 더 나쁘다.
|
||||
if (!DialogService.Confirm("종료",
|
||||
$"'{designer.DisplayName}' 을(를) 저장하지 못했습니다.",
|
||||
"변경을 버리고 종료할까요? 저장하려면 '취소'를 누르고 원인을 확인하세요.",
|
||||
yes: "버리고 종료", no: "취소", destructive: true))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
@@ -456,8 +493,7 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
await LoadSheetListAsync();
|
||||
|
||||
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
|
||||
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
|
||||
// 기동 인자로 서식을 지정했으면 그것만 연다 — 레거시도 그렇다.
|
||||
if (PendingSheetCode is { Length: > 0 } shtCod)
|
||||
{
|
||||
PendingSheetCode = null;
|
||||
@@ -468,22 +504,22 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
OnNewFile();
|
||||
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
|
||||
// 아무것도 열지 않은 상태로 둔다. 전에는 빈 새 문서를 만들어 두었는데,
|
||||
// 그건 <b>저장할 수 없는 문서</b>다 — 서식 코드가 없으니 DB 저장이 막히고,
|
||||
// 사용자는 한참 그려 넣은 뒤에야 그 사실을 알게 된다.
|
||||
// 서식은 반드시 목록에서 골라 연다(그래야 코드·권한·기존 디자인이 함께 온다).
|
||||
SelectedLeftTabIndex = 0;
|
||||
StatusText = "서식 목록에서 서식을 골라 여세요.";
|
||||
}
|
||||
|
||||
private void OnNewFile()
|
||||
{
|
||||
try
|
||||
{
|
||||
AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
|
||||
StatusText = "새 서식 (720×856)";
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DialogService.ShowError("새 서식 만들기", ex);
|
||||
}
|
||||
}
|
||||
/// <summary>
|
||||
/// 빈 문서를 붙인다 — <b>진단 전용</b>이다(--dialog-shots 가 화면을 찍으려면 문서가 필요하다).
|
||||
///
|
||||
/// UI 에는 '새 서식'이 없다. 코드 없는 문서는 DB 에 저장할 수 없고,
|
||||
/// 그린 뒤에 그것을 알게 되는 것이 이 앱에서 가장 비싼 실수였다.
|
||||
/// </summary>
|
||||
internal void AttachBlankForDiagnostics()
|
||||
=> AttachDocument(new DesignerViewModel(dataBusiness.CreateNew()));
|
||||
|
||||
private void OnOpenFile()
|
||||
{
|
||||
@@ -559,30 +595,97 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
}
|
||||
}
|
||||
|
||||
private void OnOpenFromDb()
|
||||
/// <summary>
|
||||
/// 제자리 정정 — 레거시 '기록지 수정'.
|
||||
///
|
||||
/// 저장과 무엇이 다른가: 저장은 새 버전을 만들어 그것이 활성이 되고 옛 것이 이력이 된다.
|
||||
/// 수정은 <b>지금 쓰이는 버전을 그 자리에서 고친다</b> — SdgKey 가 안 바뀌므로
|
||||
/// EMR 이 참조하던 키가 유지되고, 옛 내용만 이력으로 떨어진다.
|
||||
///
|
||||
/// 그래서 새 컨트롤을 <b>막는다</b>. 수정은 E_SctMst 를 안 건드리므로 새 컨트롤은 매핑 행 없이
|
||||
/// 저장되고 EMR 에서 조용히 값이 안 채워진다. 레거시는 이것을 산문으로만 경고했고
|
||||
/// ("새로운 컨트롤이 추가될 경우 수정이 아닌 저장을 이용하시기 바랍니다") 막지는 않았다.
|
||||
/// 경고문을 읽지 않으면 그대로 번지므로 여기서는 이름을 대조해 막는다.
|
||||
/// </summary>
|
||||
private bool OnModifyInDb()
|
||||
{
|
||||
if (CurrentDesigner is not { } designer)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
try
|
||||
{
|
||||
if (!EnsureDb())
|
||||
if (!EnsureDb() || !dataBusiness.CanWriteDb)
|
||||
{
|
||||
return;
|
||||
DialogService.Notify(DialogKind.Warning, "수정",
|
||||
"HIS 사용자가 확인되지 않아 수정할 수 없습니다.",
|
||||
"이력에 남길 수정자를 특정할 수 없습니다.");
|
||||
return false;
|
||||
}
|
||||
var dialog = new Views.SheetOpenDialogView(keyword => dataBusiness.ListSheets(keyword))
|
||||
// 저장 경로와 같은 게이트 — 열기 이후 마스터에서 플래그가 꺼졌을 수 있다
|
||||
if (!EnsureDesignAllowed(designer.Document.FormId))
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
};
|
||||
if (dialog.ShowDialog() != true || dialog.SelectedSheet is null)
|
||||
{
|
||||
return;
|
||||
return false;
|
||||
}
|
||||
OpenDbSheet(dialog.SelectedSheet.ShtCod);
|
||||
if (!FormDesignDataBusiness.CanModify())
|
||||
{
|
||||
DialogService.Notify(DialogKind.Warning, "수정 권한 없음",
|
||||
FormDesignDataBusiness.ModifyDenyReason(),
|
||||
"저장(새 버전 만들기)은 그대로 쓸 수 있습니다.");
|
||||
return false;
|
||||
}
|
||||
CommitPendingEdits();
|
||||
var document = designer.Document;
|
||||
|
||||
var added = dataBusiness.NewControlNames(document);
|
||||
if (added.Count > 0)
|
||||
{
|
||||
var sample = string.Join(", ", added.Take(5));
|
||||
DialogService.Notify(DialogKind.Warning, "수정할 수 없습니다",
|
||||
$"이 서식에 새 컨트롤 {added.Count}개가 추가되었습니다 — {sample}"
|
||||
+ (added.Count > 5 ? " 외" : string.Empty),
|
||||
"수정은 지금 버전의 글자만 고칩니다. 새 컨트롤은 EMR 연결 정보가 만들어지지 않아 "
|
||||
+ "화면에서 값이 비게 됩니다. '저장'으로 새 버전을 만드세요.");
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!DialogService.Confirm("수정 확인",
|
||||
$"[{document.FormId}] {document.Title} — 지금 쓰이는 버전을 그 자리에서 고칩니다.",
|
||||
"새 버전을 만들지 않으므로 EMR 이 참조하는 키가 그대로 유지됩니다.\n"
|
||||
+ "지금 내용은 이력으로 남습니다.\n\n"
|
||||
+ "기술 정보 — 활성 SdgKey 의 SdgDesign 을 갱신하고, 옛 XML 을 SdgDelYon='Y' 행으로 넣습니다. "
|
||||
+ "E_SctMst 는 건드리지 않습니다.",
|
||||
yes: "수정", no: "취소"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
var sdgKey = dataBusiness.ModifyInDb(document);
|
||||
designer.IsFromDb = true;
|
||||
designer.Undo.MarkSaved();
|
||||
designer.NotifyDisplayNameChanged();
|
||||
StatusText = $"수정 완료: {document.FormId} → SdgKey {sdgKey} 제자리 갱신";
|
||||
_ = LoadSheetListAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Error, "오류", "DB에서 서식을 여는 중 오류가 발생했습니다.", ex.Message);
|
||||
DialogService.Notify(DialogKind.Error, "오류",
|
||||
"수정 중 오류가 발생했습니다. (트랜잭션 롤백됨)", ex.Message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// 'DB에서 열기' 대화상자(680×560)를 지웠다.
|
||||
//
|
||||
// 좌측 '서식 목록' 탭이 <b>같은 조회</b>를 상시로 한다(둘 다 ListSheets, WHERE·ORDER BY·상한 동일).
|
||||
// 다만 같은 조회가 곧 같은 쓸모는 아니라, 그 창이 덮고 있던 구멍 넷을 먼저 좌측에 옮겼다:
|
||||
// ① 저장·신규 등록 뒤 재조회 — 이게 없어서 방금 저장한 서식이 목록에 안 떴다(가장 컸다)
|
||||
// ② 서식명 툴팁 — 좌측은 폭이 148px 남짓이라 긴 이름을 볼 방법이 없었다
|
||||
// ③ '디자인 없음'과 '서식생성기 사용 꺼짐'이 같은 회색이던 것을 툴팁으로 갈랐다
|
||||
// ④ Enter 로 열기 — 좌측은 더블클릭 전용이라 키보드로는 열 수 없었다
|
||||
// Ctrl+O 도 여기로 온다(전에는 OS 파일 대화상자였다).
|
||||
|
||||
/// <summary>
|
||||
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
|
||||
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
|
||||
@@ -667,10 +770,22 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
document.Title = register.SheetName;
|
||||
}
|
||||
|
||||
// 문면을 두 층으로 가른다.
|
||||
//
|
||||
// 이 대화상자는 <b>세 부류가 다 보는 유일한 위험 지점</b>이다 —
|
||||
// 사내 개발·지원, 병원 전산팀, 의료진·간호부. 그런데 본문에
|
||||
// E_SdgMst/E_SctMst, SdgDelYon='Y', ShtCneYon='Y' 가 그대로 노출되어 있었다.
|
||||
// 사내 인력에게는 정확한 정보이고, 전산팀에게는 읽을 수 없는 문장이고,
|
||||
// 간호부에게는 공포다. 위층은 사람 말로, 테이블·컬럼은 접힌 아래층에 둔다
|
||||
// (상세는 접혀 있고 사내 인력이 펼쳐 본다).
|
||||
var inPlace = "제자리 갱신 서식이면 지금 버전이 바로 갱신됩니다.";
|
||||
var confirm = DialogService.Confirm("DB 저장 확인",
|
||||
$"서식 [{document.FormId}] {document.Title} 을(를) DB(E_SdgMst/E_SctMst)에 저장할까요?",
|
||||
"기존 활성 디자인은 이력(SdgDelYon='Y')으로 보존되고 새 버전이 생성됩니다.\n" +
|
||||
"(제자리 갱신 서식(ShtCneYon='Y')은 기존 버전이 갱신됩니다)",
|
||||
$"[{document.FormId}] {document.Title} — 이 서식의 새 버전을 만듭니다.",
|
||||
"지금 쓰이는 버전은 이력으로 남고, 앞으로는 새 버전이 열립니다.\n"
|
||||
+ inPlace + "\n\n"
|
||||
+ "기술 정보 — E_SdgMst/E_SctMst 에 기록합니다. "
|
||||
+ "기존 활성 디자인은 SdgDelYon='Y' 로 이력 전환되고 새 SdgKey 가 생성됩니다. "
|
||||
+ "ShtCneYon='Y' 서식은 기존 버전을 갱신합니다.",
|
||||
yes: "저장", no: "취소");
|
||||
if (!confirm)
|
||||
{
|
||||
@@ -697,9 +812,19 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
$"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.",
|
||||
"그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요.");
|
||||
}
|
||||
// 완료 알림 모달을 띄우지 않는다. 정보량이 0 이었다 — SdgKey 는 바로 위에서 상태바에 넣고,
|
||||
// 같은 값을 모달로 한 번 더 보여 주는 것이었다.
|
||||
// 하루 30회 저장하는 사람에게 그건 Enter 를 30번 치는 의식이고,
|
||||
// 그때마다 화면 전체가 어두워졌다(스크림). 실패는 계속 모달로 알린다 —
|
||||
// 그건 진행이 정의되지 않는 상태이고, 조용히 넘기면 저장된 줄 알고 넘어간다.
|
||||
StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}";
|
||||
DialogService.Notify(DialogKind.Info, "DB 저장",
|
||||
$"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요.");
|
||||
// 좌측 목록을 다시 읽는다.
|
||||
//
|
||||
// 목록은 기동 때 한 번과 검색 버튼으로만 채워졌고, 저장·신규 등록 뒤에 갱신되지 않았다.
|
||||
// 그래서 <b>방금 저장한 서식이 목록에 안 떴다</b> — 지금까지 그 구멍을
|
||||
// 'DB에서 열기' 대화상자가 덮고 있었다(그 창은 열 때마다 재조회했다).
|
||||
// 그 창을 지우려면 여기가 먼저 메워져야 한다.
|
||||
_ = LoadSheetListAsync();
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -840,10 +965,9 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
return;
|
||||
}
|
||||
new Views.PreviewWindow(CurrentDesigner)
|
||||
{
|
||||
Owner = Application.Current.MainWindow,
|
||||
}.Show();
|
||||
// 창은 하나만 뜬다 — 여러 장이 겹치면 각각 다른 시점의 서식을 들고 있게 되고,
|
||||
// 환자 데이터가 들어가면 옛 창이 옛 환자를 계속 띄운다.
|
||||
Views.PreviewWindow.ShowFor(CurrentDesigner, Application.Current.MainWindow);
|
||||
}
|
||||
|
||||
private void OnPrint()
|
||||
@@ -854,9 +978,12 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
{
|
||||
return;
|
||||
}
|
||||
// 미리보기를 안 거친 인쇄도 세션 환자를 따른다 — 해석기를 안 넘기면
|
||||
// 태그 이름이 그대로 인쇄된다(미리보기 인쇄에서 실제로 그랬다).
|
||||
var (tags, fields) = Services.PatientSession.ResolversFor(CurrentDesigner.Document);
|
||||
Services.PrintService.Print(CurrentDesigner, CurrentDesigner.Document.Title.Length > 0
|
||||
? CurrentDesigner.Document.Title
|
||||
: CurrentDesigner.Document.FormId);
|
||||
: CurrentDesigner.Document.FormId, tags, fields);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -55,6 +55,22 @@ public sealed class SpacingMarkInfo
|
||||
public double TextY { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>잠긴 컨트롤 하나의 자리 — 월드 좌표. 오버레이가 점선 테두리로 그린다.</summary>
|
||||
public sealed class LockMarkInfo
|
||||
{
|
||||
/// <summary>좌상단 X(월드)</summary>
|
||||
public double X { get; init; }
|
||||
|
||||
/// <summary>좌상단 Y(월드)</summary>
|
||||
public double Y { get; init; }
|
||||
|
||||
/// <summary>너비(월드)</summary>
|
||||
public double W { get; init; }
|
||||
|
||||
/// <summary>높이(월드)</summary>
|
||||
public double H { get; init; }
|
||||
}
|
||||
|
||||
/// <summary>탭순서 배지 1개 — 월드 좌표(컨트롤 좌상단)</summary>
|
||||
public sealed class TabBadgeInfo
|
||||
{
|
||||
@@ -100,6 +116,10 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
private double selW;
|
||||
private double selH;
|
||||
private bool showHandles;
|
||||
private string dragBadgeText = string.Empty;
|
||||
private bool showDragBadge;
|
||||
private double dragBadgeX;
|
||||
private double dragBadgeY;
|
||||
private bool hasMarquee;
|
||||
private double marX;
|
||||
private double marY;
|
||||
@@ -172,6 +192,54 @@ public sealed class SelectionOverlayViewModel : ViewModelBase
|
||||
|
||||
/// <summary>탭순서 배지 목록(탭순서 편집 모드 전용)</summary>
|
||||
public ObservableCollection<TabBadgeInfo> TabBadges { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// 끄는 동안 보여 주는 치수·좌표 — <b>드래그 중에만</b> 있다.
|
||||
///
|
||||
/// 전에는 숫자가 300px 떨어진 인스펙터에만 떴다. 라벨 폭을 종이 칸에 맞추려면
|
||||
/// 끌고, 손 떼고, 오른쪽을 보고, 다시 끄는 왕복이 붙었다.
|
||||
/// 간격 수치 칩은 <b>균등 스냅이 성립하는 순간에만</b> 나타나므로
|
||||
/// "지금 이게 몇 px 인가"는 드래그 중에 알 방법이 없었다.
|
||||
/// </summary>
|
||||
public string DragBadgeText { get => dragBadgeText; private set => SetProperty(ref dragBadgeText, value); }
|
||||
|
||||
/// <summary>치수 칩을 보일지</summary>
|
||||
public bool ShowDragBadge { get => showDragBadge; private set => SetProperty(ref showDragBadge, value); }
|
||||
|
||||
/// <summary>치수 칩 X(월드) — 선택 상자 좌하단 바깥</summary>
|
||||
public double DragBadgeX { get => dragBadgeX; private set => SetProperty(ref dragBadgeX, value); }
|
||||
|
||||
/// <summary>치수 칩 Y(월드)</summary>
|
||||
public double DragBadgeY { get => dragBadgeY; private set => SetProperty(ref dragBadgeY, value); }
|
||||
|
||||
/// <summary>
|
||||
/// 끄는 동안의 치수·좌표를 갱신한다 — 이동이면 좌표, 크기 조절이면 크기를 보여 준다.
|
||||
/// 사용자가 지금 <b>바꾸고 있는 값</b>을 보여 주는 것이 요점이다.
|
||||
/// </summary>
|
||||
public void SetDragBadge(Rect bounds, bool resizing)
|
||||
{
|
||||
DragBadgeText = resizing
|
||||
? $"{Math.Round(bounds.Width)} × {Math.Round(bounds.Height)}"
|
||||
: $"X {Math.Round(bounds.X)} · Y {Math.Round(bounds.Y)}";
|
||||
// 선택 상자 <b>아래</b>에 둔다 — 위에 두면 위쪽으로 끌 때 커서가 칩을 밀고 다닌다
|
||||
DragBadgeX = bounds.X;
|
||||
DragBadgeY = bounds.Bottom + (6 * InverseZoom);
|
||||
ShowDragBadge = true;
|
||||
}
|
||||
|
||||
/// <summary>드래그가 끝났다 — 칩을 치운다</summary>
|
||||
public void ClearDragBadge() => ShowDragBadge = false;
|
||||
|
||||
/// <summary>
|
||||
/// 잠긴 컨트롤의 테두리 — <b>편집 크롬</b>이라 종이에는 그려지지 않는다.
|
||||
///
|
||||
/// 이 층에 두는 이유가 있다. 종이 층(PageView)에 표시를 넣으면 <c>--db-render</c> 게이트가
|
||||
/// 그것을 찍어 md5 가 깨진다 — 그 게이트는 레거시와 픽셀이 같다는 것을 잠그고 있다.
|
||||
///
|
||||
/// 표시가 필요한 이유: 숨김은 0.25 투명이라 티가 나는데 <b>잠금은 아무 표시가 없었다</b>.
|
||||
/// 클릭도 드래그도 안 되는데 이유가 화면에 없으니 "왜 이것만 안 잡히지"만 남는다.
|
||||
/// </summary>
|
||||
public ObservableCollection<LockMarkInfo> LockMarks { get; } = new();
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.DataSourceResultDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="데이터소스 조회 결과" Width="980" Height="640"
|
||||
MinWidth="760" MinHeight="440"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<DockPanel Margin="16">
|
||||
|
||||
<TextBlock x:Name="HeaderText" DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button Content="SQL 복사" Padding="14,4" Click="OnCopySql"
|
||||
ToolTip="치환을 마친 SQL 을 클립보드로 — 쿼리 편집기나 SQL 도구에 그대로 붙일 수 있습니다"/>
|
||||
<Button Content="닫기" Padding="18,4" Margin="8,0,0,0" IsCancel="True" IsDefault="True" Style="{StaticResource Primary}"/>
|
||||
</StackPanel>
|
||||
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="220"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<ListBox x:Name="SourceList" SelectionChanged="OnSourceChanged"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
AutomationProperties.Name="데이터소스 목록">
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<!-- 실패한 것을 목록에서 바로 알아야 한다 — 하나씩 눌러 보게 만들면 안 된다 -->
|
||||
<TextBlock Text="{Binding Mark}" Width="16" FontWeight="Bold">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Success}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Ok}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Name}"/>
|
||||
<TextBlock Text="{Binding Suffix}" Margin="6,0,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
</StackPanel>
|
||||
</DataTemplate>
|
||||
</ListBox.ItemTemplate>
|
||||
</ListBox>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch" Background="Transparent"/>
|
||||
|
||||
<DockPanel Grid.Column="2">
|
||||
<!--
|
||||
치환을 마친 SQL 이 이 창의 핵심이다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||
결과가 아니라 <b>무엇을 물었는가</b>다 — 치환이 빈 값이 되면 조건이 사라진
|
||||
SQL 이 되고, 그건 SQL 을 봐야만 알 수 있다.
|
||||
-->
|
||||
<TextBlock DockPanel.Dock="Top" Text="치환을 마친 SQL" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<TextBox x:Name="SqlBox" DockPanel.Dock="Top" Height="150" Margin="0,0,0,8"
|
||||
IsReadOnly="True" AcceptsReturn="True" TextWrapping="Wrap"
|
||||
VerticalScrollBarVisibility="Auto" FontFamily="Consolas"
|
||||
AutomationProperties.Name="치환을 마친 SQL"/>
|
||||
|
||||
<TextBlock x:Name="StatusText" DockPanel.Dock="Top" Margin="0,0,0,6" TextWrapping="Wrap"/>
|
||||
|
||||
<DataGrid x:Name="ResultGrid" IsReadOnly="True" AutoGenerateColumns="True"
|
||||
HeadersVisibility="Column" GridLinesVisibility="All"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
AutomationProperties.Name="조회 결과"/>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,144 @@
|
||||
using System.Data;
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// MDataTable 조회 결과를 펼쳐 본다.
|
||||
///
|
||||
/// <b>왜 이 창이 필요한가.</b> 종이에는 <c>[MDataTable1.ALGYON — 조회 결과가 0행입니다]</c>처럼
|
||||
/// 한 줄만 나온다. 그것으로는 세 가지를 가릴 수 없다 —
|
||||
/// 쿼리 자체가 틀렸는가, 치환이 빈 값이 되어 조건이 사라졌는가, 이 환자에게 정말 자료가 없는가.
|
||||
/// 셋은 고칠 곳이 전부 다르다.
|
||||
///
|
||||
/// 그래서 <b>치환을 마친 SQL</b>을 그대로 보여 준다. 값이 안 나왔을 때 사람이 봐야 하는 것은
|
||||
/// 결과가 아니라 무엇을 물었는가다. 레거시에는 이걸 볼 수단이 없었다 —
|
||||
/// 런타임이 빈 catch 로 삼켜서 현장에서 "빈칸"만 남았다.
|
||||
/// </summary>
|
||||
public partial class DataSourceResultDialogView : Window
|
||||
{
|
||||
#region Nested Types
|
||||
/// <summary>목록 한 줄 — 실패한 것을 눌러 보지 않고도 알아야 한다</summary>
|
||||
private sealed record Row(string Name, bool Ok, string Suffix, MDataTableResult Source)
|
||||
{
|
||||
public string Mark => Ok ? "✓" : "✕";
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
/// <summary>진단(--dialog-shots)이 직접 만든다 — 앱 코드는 ShowFor 를 쓸 것</summary>
|
||||
internal DataSourceResultDialogView(MDataTableRunner runner)
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var results = runner.RunAll();
|
||||
var rows = results.Select(r => new Row(
|
||||
r.Name, r.Ok,
|
||||
r.Ok ? $"{r.Rows.Count}행" : "실패",
|
||||
r)).ToList();
|
||||
SourceList.ItemsSource = rows;
|
||||
|
||||
HeaderText.Text = runner.HasContext
|
||||
? $"데이터소스 {rows.Count}개 · 실패 {rows.Count(r => !r.Ok)}개."
|
||||
+ " 최대 20행까지만 읽습니다(읽기 전용 · SELECT/WITH 만 실행)."
|
||||
: "환자를 고르지 않았습니다 — 치환 변수가 전부 빈 값이 되어 결과가 실제와 다릅니다."
|
||||
+ " 미리보기 창에서 '환자 선택'을 먼저 하세요.";
|
||||
|
||||
if (rows.Count > 0)
|
||||
{
|
||||
SourceList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
public static void ShowFor(MDataTableRunner runner, Window? owner)
|
||||
=> new DataSourceResultDialogView(runner) { Owner = owner }.ShowDialog();
|
||||
|
||||
private void OnSourceChanged(object sender, SelectionChangedEventArgs e)
|
||||
{
|
||||
if (SourceList.SelectedItem is not Row row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var result = row.Source;
|
||||
SqlBox.Text = result.Sql;
|
||||
|
||||
var notes = new List<string>();
|
||||
if (result.Error is { Length: > 0 } error)
|
||||
{
|
||||
notes.Add("실패: " + error);
|
||||
}
|
||||
else
|
||||
{
|
||||
notes.Add($"{result.Rows.Count}행 · 컬럼 {result.Columns.Count}개");
|
||||
}
|
||||
if (result.Unknown.Count > 0)
|
||||
{
|
||||
// 여기가 가장 흔한 원인이다 — 아직 안 옮긴 변수가 빈 문자열로 박혀 조건이 사라진다.
|
||||
// SQL 만 봐서는 "원래 그런 조건인지" 알 수 없으므로 따로 말한다.
|
||||
notes.Add("아직 옮기지 않은 변수가 빈 값으로 들어갔습니다: "
|
||||
+ string.Join(", ", result.Unknown));
|
||||
}
|
||||
StatusText.Text = string.Join(" · ", notes);
|
||||
StatusText.Foreground = (System.Windows.Media.Brush)FindResource(
|
||||
result.Ok && result.Unknown.Count == 0 ? "B.Muted" : "B.Danger");
|
||||
|
||||
ResultGrid.ItemsSource = ToTable(result)?.DefaultView;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 결과를 DataGrid 가 받을 표로. 컬럼명이 중복되거나 비면 <c>DataTable</c> 이 예외를 낸다 —
|
||||
/// 운영 쿼리에는 별칭 없는 표현식(<c>COUNT(*)</c> 등)이 흔하므로 여기서 이름을 만들어 준다.
|
||||
/// </summary>
|
||||
private static DataTable? ToTable(MDataTableResult result)
|
||||
{
|
||||
if (result.Columns.Count == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
var table = new DataTable();
|
||||
var used = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var name in result.Columns)
|
||||
{
|
||||
var candidate = name.Length > 0 ? name : "컬럼";
|
||||
var unique = candidate;
|
||||
var n = 2;
|
||||
while (!used.Add(unique))
|
||||
{
|
||||
unique = $"{candidate}_{n++}";
|
||||
}
|
||||
table.Columns.Add(unique, typeof(string));
|
||||
}
|
||||
foreach (var cells in result.Rows)
|
||||
{
|
||||
var values = new object[table.Columns.Count];
|
||||
for (var i = 0; i < values.Length; i++)
|
||||
{
|
||||
values[i] = i < cells.Length ? cells[i] ?? string.Empty : string.Empty;
|
||||
}
|
||||
table.Rows.Add(values);
|
||||
}
|
||||
return table;
|
||||
}
|
||||
|
||||
private void OnCopySql(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (SqlBox.Text.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Clipboard.SetText(SqlBox.Text);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// 클립보드는 다른 프로세스가 잡고 있으면 실패한다 — 조용히 넘기면 복사된 줄 안다
|
||||
DialogService.Notify(DialogKind.Warning, "복사", "클립보드에 넣지 못했습니다.", ex.Message, this);
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
@@ -1,29 +1,58 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.DataTableFieldDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="데이터 필드 배선" Width="560" Height="520"
|
||||
Title="값 가져오기" Width="620" Height="560"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
<DockPanel Margin="14">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,10"
|
||||
Text="데이터소스(조회 결과)의 한 칸을 이 컨트롤에 꽂습니다. 형식이 어긋나면 런타임이 아무 경고 없이 값을 비워 두므로, 문법은 여기서 조립합니다."/>
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<!--
|
||||
이 창은 "조회 결과의 한 칸을 이 컨트롤에 채우는" 설정이다.
|
||||
|
||||
<!-- 아래쪽 버튼 -->
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="배선 지우기" Padding="12,4" Click="OnClear"/>
|
||||
<Button x:Name="OkButton" Content="적용" Padding="18,4" Margin="8,0,0,0"
|
||||
<b>왜 다시 썼나.</b> 전에는 네 칸(형태·필터·행 번호·필드)이 같은 굵기로 나란히 있어서
|
||||
넷 다 채워야 하는 것처럼 보였다. 정작 <b>필수는 둘</b>(데이터소스·컬럼)이고
|
||||
그 둘이 화면에서 가장 눈에 안 띄는 자리에 있었다.
|
||||
게다가 선언 순서 때문에 첫 Tab 정지점이 '배선 지우기' 라,
|
||||
화면이 "무엇을 먼저 하나"의 답을 '지우기' 로 주고 있었다.
|
||||
|
||||
실사용 근거: 조건(필터)을 쓴 배선이 <b>0건</b>이고 행 번호는 98%가 0 이다.
|
||||
그래서 그 둘은 접어 두고, 기본값이 아닐 때만 칩으로 드러낸다.
|
||||
-->
|
||||
<DockPanel Margin="16">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,12"
|
||||
Text="서식이 열릴 때 데이터소스가 받아 온 조회 결과에서 한 칸을 읽어 이 칸을 자동으로 채웁니다. 고를 것은 둘입니다 — ① 어느 데이터소스에서, ② 그 결과의 어느 컬럼을."/>
|
||||
|
||||
<!-- 아래쪽 버튼. '가져오지 않음' 은 왼쪽으로 떼어 확인·취소와 섞이지 않게 한다 -->
|
||||
<DockPanel DockPanel.Dock="Bottom" Margin="0,12,0,0" LastChildFill="False">
|
||||
<Button DockPanel.Dock="Left" Content="가져오지 않음" Padding="12,4" Click="OnClear"
|
||||
ToolTip="이 칸을 자동으로 채우지 않습니다 — 데이터소스나 쿼리는 지워지지 않습니다"/>
|
||||
<StackPanel DockPanel.Dock="Right" Orientation="Horizontal">
|
||||
<Button x:Name="OkButton" Content="확인" Padding="18,4"
|
||||
Style="{StaticResource Primary}" Click="OnApply" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 조립 결과 미리보기 + 검증 메시지 -->
|
||||
<!-- 사람 말 한 줄 + 접힌 저장 형식 -->
|
||||
<Border DockPanel.Dock="Bottom" Margin="0,12,0,0" Padding="10,8" CornerRadius="6"
|
||||
Background="{DynamicResource B.Input}" BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1">
|
||||
<StackPanel>
|
||||
<TextBlock Text="저장될 값" FontSize="10.5" Foreground="{DynamicResource B.Muted}"/>
|
||||
<TextBlock x:Name="PreviewText" FontFamily="Consolas" FontSize="12" TextWrapping="Wrap" Margin="0,3,0,0"/>
|
||||
<TextBlock x:Name="PlainText" TextWrapping="Wrap" FontSize="12.5"/>
|
||||
<TextBlock x:Name="ProblemText" FontSize="11" TextWrapping="Wrap" Margin="0,5,0,0"
|
||||
Foreground="{DynamicResource B.Danger}" Visibility="Collapsed"/>
|
||||
<!--
|
||||
저장 원문을 없애지 않고 접어 둔다. 인스펙터 툴팁·진단 덤프·레거시 문서가
|
||||
전부 이 표기로 이야기되므로 원문을 들고 온 사람이 대조할 자리가 있어야 한다.
|
||||
다만 큰 자리를 주면 창이 '문법 조립하는 곳' 으로 스스로를 재정의한다 —
|
||||
아무것도 안 고른 상태의 첫 표시가 마침표로 시작하는 것도 그 탓이었다.
|
||||
-->
|
||||
<Expander x:Name="RawExpander" Margin="0,6,0,0" Header="저장 형식">
|
||||
<DockPanel Margin="0,4,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="복사" Padding="8,2" VerticalAlignment="Top"
|
||||
Margin="6,0,0,0" Click="OnCopyRaw"/>
|
||||
<TextBlock x:Name="RawText" FontFamily="Consolas" FontSize="11.5" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
ToolTip="레거시 XML 에 이 글자 그대로 들어갑니다"/>
|
||||
</DockPanel>
|
||||
</Expander>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
|
||||
@@ -34,39 +63,67 @@
|
||||
<ColumnDefinition Width="*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 데이터소스 -->
|
||||
<!-- ① 데이터소스 -->
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock DockPanel.Dock="Top" Text="데이터소스" Margin="0,0,0,4"/>
|
||||
<TextBlock DockPanel.Dock="Top" Text="① 가져올 데이터소스" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBlock x:Name="NoTableHint" DockPanel.Dock="Bottom" FontSize="11" TextWrapping="Wrap"
|
||||
Margin="0,6,0,0" Foreground="{DynamicResource B.Warning}" Visibility="Collapsed"
|
||||
Text="이 서식에 데이터소스가 없습니다. 팔레트에서 '데이터소스'를 먼저 놓고 쿼리를 지정하세요."/>
|
||||
<ListBox x:Name="TableList" SelectionChanged="OnFormChanged" FontSize="12.5"/>
|
||||
<!-- 도움말은 라벨 바로 아래다. Bottom 에 두면 목록이 열을 채워
|
||||
설명이 목록에서 한참 떨어진 빈 공간에 떠 있었다. -->
|
||||
<TextBlock DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,0,0,6"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="이 서식에 놓아 둔 조회 상자입니다 — 목록에서만 고릅니다."/>
|
||||
<ListBox x:Name="TableList" SelectionChanged="OnTableChanged" FontSize="12.5"/>
|
||||
</DockPanel>
|
||||
|
||||
<!-- 나머지 조각 -->
|
||||
<StackPanel Grid.Column="2">
|
||||
<TextBlock Text="형태" Margin="0,0,0,4"/>
|
||||
<ComboBox x:Name="FormBox" SelectionChanged="OnFormChanged"
|
||||
ToolTip="Select 형은 레거시 편집기가 만드는 형태, Rows 형은 손으로 쓰던 형태입니다. 연 값의 형태를 그대로 유지합니다.">
|
||||
<ComboBoxItem Content="Select — 필터로 행을 고름"/>
|
||||
<ComboBoxItem Content="Rows — 행 번호로 직접"/>
|
||||
</ComboBox>
|
||||
<!-- ② 컬럼 + ③ 접힌 행 고르기 -->
|
||||
<DockPanel Grid.Column="2">
|
||||
<TextBlock DockPanel.Dock="Top" Text="② 가져올 컬럼" FontWeight="SemiBold" Margin="0,0,0,4"/>
|
||||
<TextBox x:Name="FieldBox" DockPanel.Dock="Top" TextChanged="OnFormChanged"/>
|
||||
<TextBlock x:Name="FieldHint" DockPanel.Dock="Top" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
|
||||
<TextBlock x:Name="FilterLabel" Text="필터" Margin="0,10,0,4"/>
|
||||
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"
|
||||
ToolTip="DataTable.Select 문법 (예: CODCOD='A'). 비워 두면 전체 행입니다. 큰따옴표는 쓸 수 없습니다."/>
|
||||
|
||||
<TextBlock Text="행 번호" Margin="0,10,0,4"/>
|
||||
<TextBox x:Name="RowBox" TextChanged="OnFormChanged" ToolTip="0부터 셉니다"/>
|
||||
|
||||
<TextBlock Text="필드" Margin="0,10,0,4"/>
|
||||
<TextBox x:Name="FieldBox" TextChanged="OnFormChanged"
|
||||
ToolTip="조회 결과의 컬럼명. SQL 별칭 표현식도 됩니다 (예: A||'-'||B). 큰따옴표는 쓸 수 없습니다."/>
|
||||
|
||||
<TextBlock x:Name="FieldHint" FontSize="11" TextWrapping="Wrap" Margin="0,6,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="필드 후보는 쿼리를 실행해야 알 수 있어 목록으로 제시하지 않습니다. 데이터소스의 쿼리를 열어 컬럼명을 확인하세요."/>
|
||||
<!--
|
||||
③ 은 접어 둔다. 조건을 쓴 실사용 배선이 0건이고 행 번호는 98%가 0 이라,
|
||||
첫 방문자가 평생 안 쓸 개념을 먼저 보게 할 이유가 없다.
|
||||
기본값이 아닐 때만 머리에 칩을 띄워 접혀 있어도 숨지 않게 한다.
|
||||
-->
|
||||
<Expander x:Name="RowExpander" DockPanel.Dock="Top" Margin="0,14,0,0">
|
||||
<Expander.Header>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<TextBlock Text="③ 여러 행 중에서 고르기"/>
|
||||
<Border x:Name="RowChip" Margin="8,0,0,0" Padding="6,1" CornerRadius="3"
|
||||
Background="{DynamicResource B.Chip}" Visibility="Collapsed">
|
||||
<TextBlock x:Name="RowChipText" FontSize="10.5"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
</Border>
|
||||
</StackPanel>
|
||||
</Expander.Header>
|
||||
<StackPanel Margin="0,8,0,0">
|
||||
<TextBlock Text="몇 번째 행" Margin="0,0,0,4"/>
|
||||
<TextBox x:Name="RowBox" TextChanged="OnFormChanged"/>
|
||||
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="첫 행이 0 입니다. 1 은 두 번째 행입니다. 반각 숫자만 됩니다."/>
|
||||
|
||||
<!--
|
||||
조건은 저장된 값이 조건 형태일 때만 보인다. 새로 만들 때는 아예 안 보인다 —
|
||||
실사용에서 조건을 쓴 배선이 0건이고, 이 문법은 SQL WHERE 가 아니라
|
||||
이미 받아 온 표를 거르는 식이라 NVL·TO_CHAR·|| 가 통하지 않는다.
|
||||
-->
|
||||
<StackPanel x:Name="FilterArea" Visibility="Collapsed">
|
||||
<TextBlock Text="행 조건" Margin="0,12,0,4"/>
|
||||
<TextBox x:Name="FilterBox" TextChanged="OnFormChanged"/>
|
||||
<TextBlock FontSize="11" TextWrapping="Wrap" Margin="0,4,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="이 배선은 조건으로 행을 고르게 저장돼 있습니다. SQL 의 WHERE 가 아니라 받아 온 표를 거르는 식입니다 — 되는 예 CODCOD='A' · 안 되는 예 NVL, TO_CHAR, ||"/>
|
||||
<Button x:Name="DropFilterButton" Content="조건을 버리고 순서대로 고르기"
|
||||
Margin="0,8,0,0" Padding="10,3" Click="OnDropFilter"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Expander>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
|
||||
@@ -6,30 +6,74 @@ using SheetMe.Core.Catalog;
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// DataTableField 전용 편집기 — 문법 골격을 코드가 조립하고, 저장 전에 검사한다.
|
||||
/// 조회 결과의 한 칸을 컨트롤에 채우는 설정 — 문법 골격을 코드가 조립하고 저장 전에 검사한다.
|
||||
///
|
||||
/// 레거시 편집기와 다르게 <b>기존 값을 되읽어 채운다</b>. 레거시는 열 때마다 백지에서 시작해
|
||||
/// 한 조각만 고치려 해도 전부 다시 골라야 했다(fmMDataTableField.InitializeSelectedData).
|
||||
/// 형태(Select/Rows)도 연 값 그대로 유지한다 — 운영 다수는 Rows 형이고(1,624건, 76%),
|
||||
/// 형태를 바꾸면 그 형태만 이해하는 보조 로더에서 동작이 달라질 수 있다.
|
||||
///
|
||||
/// <b>새 배선은 Rows 형이다.</b> 전에는 레거시 편집기와 같은 Select 형으로 시작했는데,
|
||||
/// 그러면 첫 화면이 굳이 <b>조건을 요구하는 어려운 쪽</b>이 된다.
|
||||
/// 동작은 같다 — 조건이 비면 <c>Select("")(0)</c> 과 <c>Rows(0)</c> 이 같은 행을 가리키고,
|
||||
/// 해석기 자신이 그 등가를 코드로 인정한다(DataTableFieldPicker 는 조건이 비어 있으면
|
||||
/// 필터 경로를 아예 타지 않는다). 실사용에서 조건을 쓴 배선은 <b>0건</b>이다.
|
||||
///
|
||||
/// <b>기존 값의 형태는 그대로 유지한다</b> — 저장된 원문을 우리가 바꾸지 않는 것이
|
||||
/// XML 왕복 무손실의 조건이다. Select→Rows 전환은 조건을 버리는 일이라
|
||||
/// 명시적 버튼으로만 하고, 버려지는 조건 원문을 확인 문구에 적어 보여 준다.
|
||||
/// </summary>
|
||||
public partial class DataTableFieldDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly IReadOnlyList<string> tables;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 이름 → 그 조회 결과의 컬럼 이름. 없으면 null(오프라인·미접속).
|
||||
///
|
||||
/// <b>여기가 이 창의 유일한 작업 포기 지점이었다.</b> 전에는 "필드 후보는 쿼리를 실행해야
|
||||
/// 알 수 있어 목록으로 제시하지 않습니다" 라고 적어 두고 사용자를 창 밖으로 보냈는데,
|
||||
/// 이 창은 모달이라 밖이 잠겨 있다 — 취소 → 데이터소스 찾기 → 쿼리 편집기 → 검증 실행 →
|
||||
/// 컬럼명 외우기 → 다시 열기. 레거시 편집기는 이 목록을 이미 보여 줬으므로 그 문구는
|
||||
/// 퇴행을 설명하는 문장이었다.
|
||||
///
|
||||
/// 실행 자체는 이 창이 하지 않는다 — 호출부가 이미 만들어 둔 러너를 함수로 감싸 넘긴다.
|
||||
/// 그래야 접속·캐시·실패 처리가 한 군데(MDataTableRunner)에만 있다.
|
||||
/// </summary>
|
||||
private readonly Func<string, IReadOnlyList<string>?>? columnsOf;
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스별 컬럼 캐시 — 이 창이 사는 동안 <b>데이터소스마다 한 번만</b> 묻는다.
|
||||
/// Refresh 는 키 입력마다 도는데 그때마다 조회하면 타이핑이 끊긴다.
|
||||
/// </summary>
|
||||
private readonly Dictionary<string, IReadOnlyList<string>?> columnCache = new(StringComparer.Ordinal);
|
||||
|
||||
private bool loading = true;
|
||||
|
||||
/// <summary>연 값의 형태 — 저장할 때 그대로 되돌려 쓴다(원문을 우리가 바꾸지 않는다)</summary>
|
||||
private DataTableFieldForm form = DataTableFieldForm.Rows;
|
||||
#endregion
|
||||
|
||||
#region Properties
|
||||
/// <summary>적용 결과 — 취소면 null, '배선 지우기'면 빈 문자열</summary>
|
||||
/// <summary>적용 결과 — 취소면 null, '가져오지 않음'이면 빈 문자열</summary>
|
||||
public string? Result { get; private set; }
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables)
|
||||
/// <param name="current">지금 저장된 값 — 없으면 새 배선</param>
|
||||
/// <param name="dataTables">이 서식의 데이터소스 이름</param>
|
||||
/// <param name="ownerLabel">대상 컨트롤 이름 — 제목에 넣어 "지금 어느 칸 차례인지"를 알린다</param>
|
||||
/// <param name="columnsOf">데이터소스의 컬럼 이름을 주는 함수(없으면 직접 입력만)</param>
|
||||
public DataTableFieldDialogView(string? current, IReadOnlyList<string> dataTables,
|
||||
string? ownerLabel = null, Func<string, IReadOnlyList<string>?>? columnsOf = null)
|
||||
{
|
||||
InitializeComponent();
|
||||
tables = dataTables;
|
||||
this.columnsOf = columnsOf;
|
||||
|
||||
if (ownerLabel is { Length: > 0 })
|
||||
{
|
||||
// 모달이라 인스펙터가 가려진다 — 여러 칸을 연달아 채울 때 어느 칸 차례인지 알 수 없었다
|
||||
Title = $"값 가져오기 — {ownerLabel}";
|
||||
}
|
||||
|
||||
foreach (var name in tables)
|
||||
{
|
||||
@@ -40,16 +84,21 @@ public partial class DataTableFieldDialogView : Window
|
||||
var spec = DataTableFieldSpec.Parse(current);
|
||||
if (spec is not null)
|
||||
{
|
||||
FormBox.SelectedIndex = spec.Form == DataTableFieldForm.Rows ? 1 : 0;
|
||||
form = spec.Form;
|
||||
TableList.SelectedItem = tables.FirstOrDefault(t => string.Equals(t, spec.TableName, StringComparison.Ordinal));
|
||||
FilterBox.Text = spec.Filter;
|
||||
RowBox.Text = spec.RowIndex.ToString(CultureInfo.InvariantCulture);
|
||||
FieldBox.Text = spec.Field;
|
||||
// 기본값이 아닌 것이 접혀 있으면 숨는다 — 그때만 펼친 채로 연다
|
||||
if (spec.RowIndex != 0 || (form == DataTableFieldForm.Select && spec.Filter.Length > 0))
|
||||
{
|
||||
RowExpander.IsExpanded = true;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// 새 배선 — 레거시 편집기와 같은 Select 형으로 시작한다
|
||||
FormBox.SelectedIndex = 0;
|
||||
// 새 배선 — 조건 없이 첫 행. 첫 화면에 평생 안 쓸 개념을 두지 않는다.
|
||||
form = DataTableFieldForm.Rows;
|
||||
RowBox.Text = "0";
|
||||
if (tables.Count == 1)
|
||||
{
|
||||
@@ -59,47 +108,134 @@ public partial class DataTableFieldDialogView : Window
|
||||
|
||||
loading = false;
|
||||
Refresh();
|
||||
|
||||
// 첫 포커스 — 데이터소스가 이미 정해졌으면 다음 할 일(컬럼)로 보낸다.
|
||||
// 이 호출이 없으면 선언 순서상 첫 Tab 정지점이 파괴적 버튼이었다.
|
||||
Loaded += (_, _) =>
|
||||
{
|
||||
if (TableList.SelectedItem is null && TableList.Items.Count > 0)
|
||||
{
|
||||
TableList.Focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
FieldBox.Focus();
|
||||
}
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 현재 입력을 명세로 옮긴다 — 행 번호는 숫자가 아니면 0 으로 본다
|
||||
/// (레거시도 빈 값을 0 으로 강제한다: fmMDataTableField.vb:135).
|
||||
/// 현재 입력을 명세로 옮긴다.
|
||||
///
|
||||
/// <b>행 번호를 조용히 0 으로 바꾸지 않는다.</b> 전에는 파싱 실패가 그대로 0 이 됐는데,
|
||||
/// 그러면 전각 '1' 을 넣은 배선이 아무 말 없이 첫 행을 가리킨다(실사용에 그런 값이 실재한다).
|
||||
/// 잘못된 입력은 <see cref="Refresh"/> 가 이유를 말하고 확인을 막는다.
|
||||
/// </summary>
|
||||
private DataTableFieldSpec CurrentSpec()
|
||||
private DataTableFieldSpec CurrentSpec() => new()
|
||||
{
|
||||
var row = int.TryParse(RowBox.Text.Trim(), NumberStyles.Integer, CultureInfo.InvariantCulture, out var parsed)
|
||||
? parsed
|
||||
: 0;
|
||||
return new DataTableFieldSpec
|
||||
{
|
||||
Form = FormBox.SelectedIndex == 1 ? DataTableFieldForm.Rows : DataTableFieldForm.Select,
|
||||
Form = form,
|
||||
TableName = TableList.SelectedItem as string ?? string.Empty,
|
||||
Filter = FilterBox.Text,
|
||||
RowIndex = row,
|
||||
Filter = form == DataTableFieldForm.Select ? FilterBox.Text : string.Empty,
|
||||
RowIndex = ParsedRow() ?? 0,
|
||||
Field = FieldBox.Text.Trim(),
|
||||
};
|
||||
|
||||
/// <summary>행 번호 — 반각 숫자로 읽히지 않으면 null</summary>
|
||||
private int? ParsedRow()
|
||||
{
|
||||
var text = RowBox.Text.Trim();
|
||||
if (text.Length == 0)
|
||||
{
|
||||
return 0; // 빈 값은 첫 행이다(레거시도 빈 값을 0 으로 본다)
|
||||
}
|
||||
return int.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
// 조건 칸은 저장된 값이 조건 형태일 때만 보인다
|
||||
FilterArea.Visibility = form == DataTableFieldForm.Select ? Visibility.Visible : Visibility.Collapsed;
|
||||
|
||||
var spec = CurrentSpec();
|
||||
var rowsForm = spec.Form == DataTableFieldForm.Rows;
|
||||
RawText.Text = spec.Compose();
|
||||
UpdateRowChip(spec);
|
||||
UpdateFieldHint(spec.TableName);
|
||||
|
||||
// Rows 형은 필터를 아예 쓰지 않는다 — 켜 두면 입력해도 무시되는 칸이 된다
|
||||
FilterBox.IsEnabled = !rowsForm;
|
||||
FilterLabel.Opacity = rowsForm ? 0.45 : 1;
|
||||
FilterBox.Opacity = rowsForm ? 0.45 : 1;
|
||||
// 전각 숫자처럼 읽을 수 없는 행 번호는 <b>먼저</b> 말한다 — 조용히 0 으로 바꾸지 않는다
|
||||
var problem = ParsedRow() is null
|
||||
? "몇 번째 행: 반각 숫자만 됩니다"
|
||||
// 고를 데이터소스가 아예 없으면 '고르세요' 는 따를 수 없는 지시다 — 다음 행동을 말한다
|
||||
: tables.Count == 0
|
||||
? "이 서식에 데이터소스가 없습니다 — 팔레트에서 먼저 놓고 쿼리를 지정하세요"
|
||||
: spec.Validate(tables);
|
||||
|
||||
PreviewText.Text = spec.Compose();
|
||||
var problem = spec.Validate(tables);
|
||||
PlainText.Text = problem is null ? PlainSentence(spec) : "아직 가져올 값이 정해지지 않았습니다.";
|
||||
ProblemText.Text = problem ?? string.Empty;
|
||||
ProblemText.Visibility = problem is null ? Visibility.Collapsed : Visibility.Visible;
|
||||
// 레거시는 아무것도 안 고르고 확인하면 ")(" 같은 찌꺼기를 저장한다 — 그 경로를 막는다
|
||||
OkButton.IsEnabled = problem is null;
|
||||
}
|
||||
|
||||
/// <summary>사람 말 한 줄 — 이 창이 무엇을 하기로 했는지</summary>
|
||||
private static string PlainSentence(DataTableFieldSpec spec)
|
||||
{
|
||||
var where = spec.RowIndex == 0 ? "첫 행" : $"{spec.RowIndex + 1}번째 행";
|
||||
var scope = spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0
|
||||
? "조건을 통과한 행 중 " + where
|
||||
: where;
|
||||
return $"서식을 열면 — {spec.TableName} 조회 결과의 {scope}에서 "
|
||||
+ $"{spec.Field} 값을 가져와 이 칸에 넣습니다.";
|
||||
}
|
||||
|
||||
/// <summary>접힌 ③ 의 머리 칩 — 기본값이 아니면 접혀 있어도 드러낸다</summary>
|
||||
private void UpdateRowChip(DataTableFieldSpec spec)
|
||||
{
|
||||
var parts = new List<string>();
|
||||
if (spec.RowIndex != 0)
|
||||
{
|
||||
parts.Add($"{spec.RowIndex + 1}번째 행");
|
||||
}
|
||||
if (spec.Form == DataTableFieldForm.Select && spec.Filter.Length > 0)
|
||||
{
|
||||
parts.Add("조건 있음");
|
||||
}
|
||||
RowChipText.Text = string.Join(" · ", parts);
|
||||
RowChip.Visibility = parts.Count > 0 ? Visibility.Visible : Visibility.Collapsed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 컬럼 칸 아래 안내 — 실제 컬럼 목록을 얻을 수 있으면 그것을 보여 준다.
|
||||
/// 못 얻으면 <b>왜 못 얻는지</b>를 말한다(전에는 "목록으로 제시하지 않습니다"로 끝났다).
|
||||
/// </summary>
|
||||
private void UpdateFieldHint(string tableName)
|
||||
{
|
||||
if (tableName.Length == 0)
|
||||
{
|
||||
FieldHint.Text = "데이터소스를 먼저 고르면 그 조회 결과의 컬럼 이름을 보여 줍니다.";
|
||||
return;
|
||||
}
|
||||
if (!columnCache.TryGetValue(tableName, out var columns))
|
||||
{
|
||||
columns = columnsOf?.Invoke(tableName);
|
||||
columnCache[tableName] = columns;
|
||||
}
|
||||
if (columns is { Count: > 0 })
|
||||
{
|
||||
FieldHint.Text = $"{tableName} 의 컬럼 {columns.Count}개 — "
|
||||
+ string.Join(", ", columns.Take(12))
|
||||
+ (columns.Count > 12 ? " …" : string.Empty);
|
||||
return;
|
||||
}
|
||||
// 못 읽었을 때도 <b>왜</b>인지 말한다. 전에는 "목록으로 제시하지 않습니다" 로 끝나
|
||||
// 사용자가 무엇을 하면 되는지 알 수 없었다. 문구는 짧게 — 여기가 도움말 자리다.
|
||||
FieldHint.Text = "컬럼 목록을 읽지 못했습니다(DB 미접속·쿼리 실패) — 직접 입력하세요."
|
||||
+ " 조회 결과 표의 머리글 이름이고 대개 전부 대문자입니다(예: ALB, ACPDTE).";
|
||||
}
|
||||
|
||||
private void OnTableChanged(object sender, SelectionChangedEventArgs e) => OnFormChanged(sender, e);
|
||||
|
||||
private void OnFormChanged(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!loading)
|
||||
@@ -108,8 +244,42 @@ public partial class DataTableFieldDialogView : Window
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 조건을 버리고 순서대로 고르기 — <b>되돌릴 수 없어</b> 버려지는 조건을 먼저 보여 준다.
|
||||
///
|
||||
/// 전에는 형태 콤보를 바꾸는 것만으로 조건이 조립에서 조용히 사라졌고(Rows 조립은 Filter 를
|
||||
/// 아예 쓰지 않는다) 다시 열면 Rows 해석에 조건 항목이 없어 영구히 없어졌다.
|
||||
/// 화면은 칸을 흐리게만 해서 값이 살아 있는 것처럼 보였다.
|
||||
/// </summary>
|
||||
private void OnDropFilter(object sender, RoutedEventArgs e)
|
||||
{
|
||||
var filter = FilterBox.Text;
|
||||
if (filter.Length > 0 && !Services.DialogService.Confirm("조건 버리기",
|
||||
"이 배선의 행 조건을 지우고 순서대로 고르게 바꿉니다.",
|
||||
$"지워지는 조건: {filter}\n확인을 누르면 되돌릴 수 없습니다.",
|
||||
destructive: true, owner: this))
|
||||
{
|
||||
return;
|
||||
}
|
||||
form = DataTableFieldForm.Rows;
|
||||
FilterBox.Text = string.Empty;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
private void OnCopyRaw(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (RawText.Text is { Length: > 0 } raw)
|
||||
{
|
||||
Clipboard.SetText(raw);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnApply(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (ParsedRow() is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var spec = CurrentSpec();
|
||||
if (spec.Validate(tables) is not null)
|
||||
{
|
||||
|
||||
@@ -17,6 +17,76 @@
|
||||
<bh:CanvasKeyboardBehavior/>
|
||||
</b:Interaction.Behaviors>
|
||||
|
||||
<!--
|
||||
캔버스 우클릭 메뉴 — 전에는 앱 전체에 우클릭 메뉴가 하나도 없었다.
|
||||
배치·정렬·같은 크기·간격·앞뒤·그룹이 전부 로고 아래 4단 메뉴 안에만 있어서,
|
||||
선택한 것을 정렬하려면 캔버스에서 손을 떼고 화면 좌상단까지 마우스를 옮겨야 했다.
|
||||
단축키(Alt+A/D/W/S/H/V …)와 같은 명령을 같은 자리에서 보여 준다 —
|
||||
단축키를 모르는 사람도 여기서 배운다.
|
||||
-->
|
||||
<UserControl.ContextMenu>
|
||||
<ContextMenu>
|
||||
<!--
|
||||
선택 확장이 <b>맨 위</b>다. 아래 정렬·간격 명령이 전제하는 '올바른 선택'을
|
||||
만드는 수단이고, 그것 없이는 매번 "마퀴로 끌고 딸려온 것 빼기"가 앞에 붙는다.
|
||||
실측(787건): 행 무리의 94.2%에 침입자가 있고 그 수가 중앙값 7개다.
|
||||
-->
|
||||
<MenuItem Header="같은 행 선택" InputGestureText="Alt+1"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Row"/>
|
||||
<MenuItem Header="같은 열 선택" InputGestureText="Alt+2"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Column"/>
|
||||
<MenuItem Header="같은 종류 선택" InputGestureText="Alt+3"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Type"/>
|
||||
<MenuItem Header="같은 크기 선택" InputGestureText="Alt+4"
|
||||
Command="{Binding SelectSameCommand}" CommandParameter="Size"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="왼쪽 맞춤" InputGestureText="Alt+A"
|
||||
Command="{Binding AlignCommand}" CommandParameter="Left"/>
|
||||
<MenuItem Header="오른쪽 맞춤" InputGestureText="Alt+D"
|
||||
Command="{Binding AlignCommand}" CommandParameter="Right"/>
|
||||
<MenuItem Header="위 맞춤" InputGestureText="Alt+W"
|
||||
Command="{Binding AlignCommand}" CommandParameter="Top"/>
|
||||
<MenuItem Header="아래 맞춤" InputGestureText="Alt+S"
|
||||
Command="{Binding AlignCommand}" CommandParameter="Bottom"/>
|
||||
<MenuItem Header="가로 가운데" InputGestureText="Alt+H"
|
||||
Command="{Binding AlignCommand}" CommandParameter="CenterH"/>
|
||||
<MenuItem Header="세로 가운데" InputGestureText="Alt+V"
|
||||
Command="{Binding AlignCommand}" CommandParameter="CenterV"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="가로 간격 균등" InputGestureText="Alt+Shift+H"
|
||||
Command="{Binding SpacingCommand}" CommandParameter="HorizEqual"/>
|
||||
<MenuItem Header="세로 간격 균등" InputGestureText="Alt+Shift+V"
|
||||
Command="{Binding SpacingCommand}" CommandParameter="VertEqual"/>
|
||||
<MenuItem Header="세로 간격 넓게" InputGestureText="Alt+]"
|
||||
Command="{Binding SpacingCommand}" CommandParameter="VertIncrease"/>
|
||||
<MenuItem Header="세로 간격 좁게" InputGestureText="Alt+["
|
||||
Command="{Binding SpacingCommand}" CommandParameter="VertDecrease"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="너비 같게" InputGestureText="Alt+Shift+W"
|
||||
Command="{Binding SizeToControlCommand}" CommandParameter="Width"/>
|
||||
<MenuItem Header="높이 같게" InputGestureText="Alt+Shift+S"
|
||||
Command="{Binding SizeToControlCommand}" CommandParameter="Height"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="그룹으로 묶기" InputGestureText="Ctrl+G"
|
||||
Command="{Binding GroupCommand}"/>
|
||||
<MenuItem Header="그룹 풀기" InputGestureText="Ctrl+Shift+G"
|
||||
Command="{Binding UngroupCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="맨 앞으로" Command="{Binding BringToFrontCommand}"/>
|
||||
<MenuItem Header="맨 뒤로" Command="{Binding SendToBackCommand}"/>
|
||||
<Separator/>
|
||||
<!--
|
||||
'잠그기'로 이름을 한정한다 — 해제는 여기서 못 한다. 히트 판정이 잠긴 것을
|
||||
후보에서 빼므로 잠긴 컨트롤에는 우클릭조차 닿지 않는다. 해제는 레이어 패널에서.
|
||||
-->
|
||||
<MenuItem Header="선택 잠그기" InputGestureText="Ctrl+L" Command="{Binding LockCommand}"/>
|
||||
<MenuItem Header="선택 숨기기" InputGestureText="Ctrl+Shift+H" Command="{Binding HideCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="복제" InputGestureText="Ctrl+D" Command="{Binding DuplicateCommand}"/>
|
||||
<MenuItem Header="삭제" InputGestureText="Del" Command="{Binding DeleteCommand}"/>
|
||||
</ContextMenu>
|
||||
</UserControl.ContextMenu>
|
||||
|
||||
<!-- 디자인 캔버스: 스크롤 + 줌(LayoutTransform) 월드. 입력은 World 1곳에서 수신 -->
|
||||
<ScrollViewer x:Name="Scroll"
|
||||
HorizontalScrollBarVisibility="Auto"
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="폰트 일괄 변경" Width="620" Height="560" MinWidth="520" MinHeight="420"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
<DockPanel Margin="12">
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<DockPanel Margin="16">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="문서 전체의 (타입·글꼴·크기) 조합입니다. 변경할 조합을 선택(다중 가능)하고 새 글꼴을 지정하세요."/>
|
||||
|
||||
@@ -23,7 +23,10 @@
|
||||
<DockPanel Margin="0,10,0,0">
|
||||
<Button DockPanel.Dock="Right" Content="닫기" Padding="16,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
<Button DockPanel.Dock="Right" Content="전체에 적용" Padding="14,4" Margin="8,0,0,0" Click="OnApplyAll"/>
|
||||
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"/>
|
||||
<!-- 강조는 <b>고른 것만</b> 바꾸는 쪽이다. '전체에 적용'은 문서 전체를 한 번에 바꾸므로
|
||||
눈이 먼저 가는 자리에 두지 않는다 — 되돌리기가 있어도 되돌릴 생각을 하려면 먼저 알아채야 한다. -->
|
||||
<Button DockPanel.Dock="Right" Content="선택 조합에 적용" Padding="14,4" Click="OnApplySelected"
|
||||
Style="{StaticResource Primary}"/>
|
||||
<TextBlock x:Name="ResultText" VerticalAlignment="Center" Foreground="{DynamicResource B.Success}"/>
|
||||
</DockPanel>
|
||||
</StackPanel>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
|
||||
<UserControl.Resources>
|
||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||
<ctl:ChoiceLabelConverter x:Key="ChoiceLabel"/>
|
||||
<BooleanToVisibilityConverter x:Key="BoolToVisibility"/>
|
||||
|
||||
<!--
|
||||
@@ -174,7 +175,8 @@
|
||||
(자르기보다 낫다 — 잘린 라벨은 툴팁을 띄워봐야 아는 정보가 된다).
|
||||
-->
|
||||
<Style x:Key="RowLabel" TargetType="TextBlock">
|
||||
<Setter Property="Width" Value="72"/>
|
||||
<!-- 위 주석이 92 를 논증해 놓고 값은 72 였다 — 그래서 '액션 대상 컨…' 처럼 잘렸다 -->
|
||||
<Setter Property="Width" Value="92"/>
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="FontSize" Value="12"/>
|
||||
@@ -299,6 +301,18 @@
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Accent2}"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Accent}"/>
|
||||
</Trigger>
|
||||
<!--
|
||||
갈림(다중선택에서 값이 서로 다름) — 표시가 없으면 '꺼짐'과 픽셀 단위로 같다.
|
||||
그러면 굵게를 켜려고 누른 한 번이 선택 전체를 '굵게 끔'으로 만들어도
|
||||
(WPF 는 불확정에서 false 로 간다) 사용자는 무엇이 일어났는지 알 수 없다.
|
||||
동작 자체는 ToggleRowViewModel.IsOn 이 '갈림에서 누르면 켠다'로 고쳤고,
|
||||
여기서는 <b>갈렸다는 사실</b>을 보이게 한다. SwitchToggle(:105-108)과 같은 표현.
|
||||
-->
|
||||
<Trigger Property="IsChecked" Value="{x:Null}">
|
||||
<Setter TargetName="bd" Property="Opacity" Value="0.55"/>
|
||||
<Setter TargetName="bd" Property="BorderBrush" Value="{DynamicResource B.Line2}"/>
|
||||
<Setter TargetName="bd" Property="Background" Value="{DynamicResource B.Line2}"/>
|
||||
</Trigger>
|
||||
</ControlTemplate.Triggers>
|
||||
</ControlTemplate>
|
||||
</Setter.Value>
|
||||
@@ -378,6 +392,38 @@
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
|
||||
<!--
|
||||
글꼴 이름 — 고르기도 되고 쓰기도 된다(IsEditable).
|
||||
목록에 가두면 이 PC 에 없는 글꼴을 쓰는 서식을 열었을 때 그 값을 고를 수 없게 되고,
|
||||
칸을 건드리는 순간 사라진다. 병원 PC 와 설치 글꼴이 다른 것이 정상이다.
|
||||
|
||||
UpdateSourceTrigger=LostFocus 인 이유: 커밋 한 번이 문서 전체 딥클론 1회다.
|
||||
글자마다 커밋하면 "맑은 고딕" 여섯 자에 스냅샷 여섯 개가 쌓인다.
|
||||
-->
|
||||
<DataTemplate DataType="{x:Type ins:FontFamilyRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
|
||||
<!-- 이 PC 에 없는 글꼴이면 알린다 — 값은 그대로 두고 '화면이 병원과 다르다'만 말한다 -->
|
||||
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
|
||||
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
|
||||
ToolTip="{Binding MissingHint}"
|
||||
Visibility="{Binding IsMissingFont, Converter={StaticResource BoolToVisibility}}"/>
|
||||
<Grid>
|
||||
<ComboBox Style="{StaticResource RowCombo}" IsEditable="True" IsTextSearchEnabled="True"
|
||||
ItemsSource="{Binding Families}"
|
||||
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<!-- 이름을 그 글꼴로 그린다 — 이름만으로는 어떤 모양인지 알 수 없다 -->
|
||||
<TextBlock Text="{Binding}" FontFamily="{Binding}" FontSize="13"/>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
<DataTemplate DataType="{x:Type ins:TextRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
|
||||
@@ -425,6 +471,15 @@
|
||||
<DataTemplate DataType="{x:Type ins:AlignRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" VerticalAlignment="Top" Margin="0,4,6,0"/>
|
||||
<!--
|
||||
갈린 값이면 9칸이 전부 꺼져 '아직 아무것도 안 고른 격자'와 똑같이 보인다.
|
||||
다른 편집기는 MixedHintOverlay 로 알리는데 이 격자만 알릴 자리가 없었다 —
|
||||
칸 위가 아니라 격자 오른쪽에 붙여 어느 칸도 가리지 않게 한다.
|
||||
-->
|
||||
<TextBlock DockPanel.Dock="Right" Text="여러 값" FontStyle="Italic" FontSize="11"
|
||||
VerticalAlignment="Top" Margin="6,6,0,0"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Visibility="{Binding IsMixed, Converter={StaticResource BoolToVisibility}}"/>
|
||||
<ItemsControl ItemsSource="{Binding Cells}">
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate><UniformGrid Columns="3"/></ItemsPanelTemplate>
|
||||
@@ -533,7 +588,28 @@
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}" ToolTip="{Binding Label}"/>
|
||||
<Grid>
|
||||
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}"/>
|
||||
<!--
|
||||
빈 항목은 '여러 값' 자리다(ChoiceRowViewModel.EnsureMixedPlaceholder).
|
||||
그냥 두면 목록에 빈 줄로 보여 무엇인지 알 수 없다.
|
||||
-->
|
||||
<ComboBox Style="{StaticResource RowCombo}" ItemsSource="{Binding Choices}" SelectedItem="{Binding ValueText}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource ChoiceLabel}}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding}" Value="">
|
||||
<Setter Property="FontStyle" Value="Italic"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</ComboBox.ItemTemplate>
|
||||
</ComboBox>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}" Margin="9,0,0,0"/>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
@@ -547,10 +623,52 @@
|
||||
<Button DockPanel.Dock="Right" Width="28" Height="26" Padding="0" Margin="4,0,0,0"
|
||||
Command="{Binding BrowseCommand}" ToolTip="전체 목록에서 선택"
|
||||
Content="{Binding Source=list, Converter={StaticResource IconName}, ConverterParameter=13}"/>
|
||||
<!--
|
||||
카탈로그에 없는 이름 — 글꼴 행과 같은 규칙이다. 값은 건드리지 않고 표식만 낸다.
|
||||
인라인으로 직접 치는 것이 이 행의 주 경로인데 검증이 하나도 없어서,
|
||||
한 글자 틀려도 조용히 커밋되고 미리보기에서 <b>빈칸</b>으로만 드러났다 —
|
||||
빈칸의 원인이 오타인지 자료가 없어서인지 가릴 수 없었다.
|
||||
-->
|
||||
<TextBlock DockPanel.Dock="Right" Text="⚠" FontSize="13" VerticalAlignment="Center"
|
||||
Margin="4,0,0,0" Foreground="{DynamicResource B.Warning}"
|
||||
ToolTip="{Binding UnknownTagHint}"
|
||||
Visibility="{Binding IsUnknownTag, Converter={StaticResource BoolToVisibility}}"/>
|
||||
<Grid>
|
||||
<TextBox Style="{StaticResource RowInput}" bh:InspectorFieldBehavior.CommitOnEnter="True" Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||
ToolTip="직접 입력하거나 아래 제안을 누르세요"/>
|
||||
<TextBox x:Name="TagInput" Style="{StaticResource RowInput}"
|
||||
bh:InspectorFieldBehavior.CommitOnEnter="True"
|
||||
bh:TagCompletionBehavior.Enabled="True"
|
||||
Text="{Binding ValueText, UpdateSourceTrigger=LostFocus}"
|
||||
ToolTip="치면 목록이 뜹니다 — ↑↓ 이동, Enter 확정, Esc 취소"/>
|
||||
<TextBlock Style="{StaticResource MixedHintOverlay}"/>
|
||||
<!--
|
||||
인라인 완성 — 이 행에서 모달 없이 태그를 고르는 주 경로.
|
||||
StaysOpen=True 가 필수다: False 면 팝업이 마우스를 캡처해 항목 클릭을
|
||||
'바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다 —
|
||||
QueryEditorWindow 의 CompletionPopup 에서 같은 것을 이미 겪었다).
|
||||
AllowsTransparency 를 켜지 않는다 — 레이어드 창이 되면 ClearType 이 꺼져
|
||||
13px 한글이 뭉개진다(진단 cleartype 의 D 표본).
|
||||
-->
|
||||
<Popup IsOpen="{Binding ShowCompletions, Mode=OneWay}" PlacementTarget="{Binding ElementName=TagInput}"
|
||||
Placement="Bottom" StaysOpen="True" Focusable="False">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line}"
|
||||
BorderThickness="1" CornerRadius="6" Padding="2"
|
||||
MinWidth="{Binding ActualWidth, ElementName=TagInput}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<ItemsControl ItemsSource="{Binding Completions}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<Border Height="24" Padding="7,0" CornerRadius="4"
|
||||
Background="Transparent"
|
||||
MouseLeftButtonDown="OnCompletionClicked">
|
||||
<TextBlock Text="{Binding}" FontFamily="Consolas" FontSize="11.5"
|
||||
VerticalAlignment="Center"
|
||||
Foreground="{DynamicResource B.Ink}"/>
|
||||
</Border>
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
</ItemsControl>
|
||||
</Border>
|
||||
</Popup>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
<!-- 이 컨트롤 타입에서 실제로 쓰이는 태그(빈도순). 아직 안 골랐을 때만 뜬다 —
|
||||
@@ -577,8 +695,15 @@
|
||||
<DataTemplate DataType="{x:Type ins:ReadOnlyRowViewModel}">
|
||||
<DockPanel Margin="0,2">
|
||||
<TextBlock Text="{Binding Label}" Style="{StaticResource RowLabel}"/>
|
||||
<!--
|
||||
이 자리는 값이 아니라 <b>설명</b>이다 — 자르면 결론이 사라진다.
|
||||
"타입이 달라 값의 뜻이 다른 속성 2개 — 타입별로 따로 고르세요" 에서
|
||||
지시에 해당하는 뒷부분이 잘려 나가 무엇을 하라는 건지 알 수 없었다.
|
||||
접어서 다 보여 주고, 그래도 좁으면 툴팁이 받는다.
|
||||
-->
|
||||
<TextBlock Text="{Binding ValueText}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||
VerticalAlignment="Center" TextTrimming="CharacterEllipsis"/>
|
||||
VerticalAlignment="Center" TextWrapping="Wrap"
|
||||
ToolTip="{Binding ValueText}"/>
|
||||
</DockPanel>
|
||||
</DataTemplate>
|
||||
|
||||
@@ -740,7 +865,10 @@
|
||||
HorizontalOffset="-176" VerticalOffset="4">
|
||||
<Border Background="{DynamicResource B.Surface}" BorderBrush="{DynamicResource B.Line2}"
|
||||
BorderThickness="1" CornerRadius="8" Padding="10" Effect="{DynamicResource B.FloatShadow}">
|
||||
<StackPanel Width="212">
|
||||
<!-- Effect 와 레이어드 팝업이 각각 중간 서피스를 만들어 ClearType 을 끈다.
|
||||
힌트는 Effect 가 걸린 바깥 Border 에 걸면 듣지 않으므로, 안쪽 불투명 배경에 건다. -->
|
||||
<StackPanel Width="212" Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<TextBlock Text="배경" FontSize="11" Foreground="{DynamicResource B.Muted}" Margin="0,0,0,5"/>
|
||||
<ItemsControl ItemsSource="{Binding BackgroundSwatches}"
|
||||
ItemTemplate="{StaticResource ColorSwatchTemplate}">
|
||||
@@ -786,11 +914,11 @@
|
||||
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center"
|
||||
IsTabStop="False" Focusable="False"
|
||||
Content="{Binding HeaderIcon, Converter={StaticResource IconName}, ConverterParameter=16|B.Muted}"/>
|
||||
<Button DockPanel.Dock="Right" Width="24" Height="24" Padding="0"
|
||||
Style="{StaticResource Subtle}" ToolTip="이 컨트롤에 대한 명령"
|
||||
AutomationProperties.Name="더보기"
|
||||
Visibility="{Binding IsSingleSelection, Converter={StaticResource BoolToVisibility}}"
|
||||
Content="{Binding Source=ellipsis-vertical, Converter={StaticResource IconName}, ConverterParameter=14|B.Muted}"/>
|
||||
<!--
|
||||
⋮ 버튼을 지웠다. Command·Click·ContextMenu 가 <b>전부 없어</b> 눌러도 아무 일이 없었는데,
|
||||
툴팁은 '이 컨트롤에 대한 명령' 이라 '단일 선택일 때만 되는 뭔가가 있다'고 오해시켰다.
|
||||
없는 버튼보다 죽은 버튼이 나쁘다. 컨트롤 단위 명령은 캔버스 우클릭 메뉴에 다 있다.
|
||||
-->
|
||||
<TextBlock Text="{Binding HeaderName}" FontWeight="Bold" FontSize="12.5"
|
||||
Foreground="{DynamicResource B.Ink}" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" ToolTip="{Binding Summary}"/>
|
||||
@@ -873,6 +1001,34 @@
|
||||
Content="{Binding Source=distribute-v, Converter={StaticResource IconName}, ConverterParameter=18|B.Muted}"/>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
간격을 숫자로 — 여기까지 간격을 정하는 길은 4px 씩 넓게/좁게뿐이었다.
|
||||
20px 를 32px 로 바꾸려면 3번, 메뉴로 하면 12왕복이었다.
|
||||
드래그 스냅은 형제 간격을 정확히 계산해 표식까지 그리는데(SnapSolver)
|
||||
명령 경로에는 그 값을 넣을 칸이 없었다.
|
||||
균등(3개 이상)과 달리 <b>둘</b>부터 뜻이 있다 — 라벨과 입력칸 한 쌍이 가장 흔하다.
|
||||
값이 갈리면 빈 칸이다. 하나로 보여 주면 이미 적용된 것처럼 읽힌다.
|
||||
-->
|
||||
<StackPanel DockPanel.Dock="Top" Orientation="Horizontal" Margin="12,0,10,6"
|
||||
Visibility="{Binding CanSpaceExactly, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock Text="간격값" VerticalAlignment="Center" FontSize="11.5" Margin="0,0,8,0"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<TextBlock Text="세로" VerticalAlignment="Center" FontSize="11"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="0,0,4,0"/>
|
||||
<TextBox Width="46" Style="{StaticResource RowInput}"
|
||||
bh:InspectorFieldBehavior.CommitOnEnter="True"
|
||||
Text="{Binding VerticalGapText, UpdateSourceTrigger=LostFocus}"
|
||||
AutomationProperties.Name="세로 간격 픽셀"
|
||||
ToolTip="숫자를 넣고 Enter — 첫 항목은 움직이지 않는다"/>
|
||||
<TextBlock Text="가로" VerticalAlignment="Center" FontSize="11"
|
||||
Foreground="{DynamicResource B.Muted}" Margin="10,0,4,0"/>
|
||||
<TextBox Width="46" Style="{StaticResource RowInput}"
|
||||
bh:InspectorFieldBehavior.CommitOnEnter="True"
|
||||
Text="{Binding HorizontalGapText, UpdateSourceTrigger=LostFocus}"
|
||||
AutomationProperties.Name="가로 간격 픽셀"
|
||||
ToolTip="숫자를 넣고 Enter — 첫 항목은 움직이지 않는다"/>
|
||||
</StackPanel>
|
||||
|
||||
<!--
|
||||
정렬 기준 — 여럿을 골라 놓고도 '서로 맞추기'와 '종이에 맞추기'는 다른 작업이다.
|
||||
왼쪽 문구는 선택에 따라 바뀐다: 여럿이면 '선택 영역', 하나면 '부모'(또는 '페이지').
|
||||
@@ -889,8 +1045,8 @@
|
||||
ToolTip="{Binding PageScopeTip}"/>
|
||||
</StackPanel>
|
||||
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
||||
<ScrollViewer x:Name="RowScroll" VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl x:Name="RowHost" ItemsSource="{Binding Rows}" Margin="10,0,10,10">
|
||||
<!-- 접힌 섹션의 행은 컨테이너만 숨긴다 — 행 VM 을 재생성하지 않아 편집 상태가 유지된다 -->
|
||||
<ItemsControl.ItemContainerStyle>
|
||||
<Style TargetType="ContentPresenter">
|
||||
|
||||
@@ -1,12 +1,194 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Controls.Primitives;
|
||||
using System.Windows.Input;
|
||||
using System.Windows.Media;
|
||||
using SheetMe.Designer.ViewModels.Inspector;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.</summary>
|
||||
/// <summary>
|
||||
/// 속성 인스펙터 뷰 — 행 VM 암시적 템플릿 렌더.
|
||||
///
|
||||
/// <b>선택이 바뀌어도 손이 이어지게 한다.</b> 선택 집합이 바뀌면 뷰모델이 행을 통째로 다시 만들어서,
|
||||
/// 그대로 두면 편집하던 칸과 스크롤 위치가 매번 사라진다. 하루 수백 건을 다루는 사람에게 그건
|
||||
/// "다음 컨트롤로 넘어가면 손이 처음으로 돌아간다"는 뜻이다 —
|
||||
/// 라벨 20개의 글꼴을 차례로 고치는 일이 20번의 스크롤·클릭이 된다.
|
||||
///
|
||||
/// 행을 키 기준으로 갱신하는 쪽이 더 근본적이지만 이 패널은 회귀 기록이 가장 많은 곳이라
|
||||
/// 이번에는 재구성을 그대로 두고 <b>포커스와 스크롤만 되돌린다</b>.
|
||||
/// 행을 라벨로 찾는다 — 타입이 달라도 같은 이름의 행(글꼴·정렬·표시)은 같은 일을 한다.
|
||||
/// </summary>
|
||||
public partial class InspectorView : UserControl
|
||||
{
|
||||
#region Member Fields
|
||||
/// <summary>재구성 직전에 포커스가 있던 행의 라벨</summary>
|
||||
private string? focusedLabel;
|
||||
|
||||
private double savedOffset;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public InspectorView()
|
||||
{
|
||||
InitializeComponent();
|
||||
DataContextChanged += OnDataContextChanged;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
private void OnDataContextChanged(object sender, DependencyPropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.OldValue is InspectorViewModel old)
|
||||
{
|
||||
old.Rebuilding -= OnRebuilding;
|
||||
old.Rebuilt -= OnRebuilt;
|
||||
}
|
||||
if (e.NewValue is InspectorViewModel fresh)
|
||||
{
|
||||
fresh.Rebuilding += OnRebuilding;
|
||||
fresh.Rebuilt += OnRebuilt;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRebuilding()
|
||||
{
|
||||
savedOffset = RowScroll.VerticalOffset;
|
||||
focusedLabel = null;
|
||||
if (Keyboard.FocusedElement is DependencyObject focused && IsDescendantOfRows(focused))
|
||||
{
|
||||
focusedLabel = RowLabelOf(focused);
|
||||
}
|
||||
}
|
||||
|
||||
private void OnRebuilt()
|
||||
{
|
||||
// 컨테이너가 아직 없다 — 레이아웃이 한 번 돌아야 행이 생긴다.
|
||||
// Loaded 나 즉시 호출로는 늘 빈 트리를 뒤지게 된다(이 저장소에서 같은 함정을 두 번 밟았다).
|
||||
Dispatcher.BeginInvoke(new Action(Restore), System.Windows.Threading.DispatcherPriority.Loaded);
|
||||
}
|
||||
|
||||
private void Restore()
|
||||
{
|
||||
RowScroll.ScrollToVerticalOffset(savedOffset);
|
||||
if (focusedLabel is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var target = FindRowByLabel(focusedLabel);
|
||||
focusedLabel = null;
|
||||
// 포커스를 강제로 옮기지 않는다 — 사용자가 그 사이에 다른 곳을 눌렀으면 그쪽이 옳다
|
||||
if (target is not null && Keyboard.FocusedElement is not TextBoxBase)
|
||||
{
|
||||
target.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 완성 목록의 한 줄을 눌렀다.
|
||||
///
|
||||
/// 팝업은 StaysOpen=True 다 — False 로 두면 팝업이 마우스를 캡처해 이 클릭을
|
||||
/// '바깥 클릭'으로 먹어 버린다(눌러도 입력이 안 되고 사라진다).
|
||||
/// 그래서 클릭을 직접 받아 값으로 확정하고 코드로 닫는다.
|
||||
/// </summary>
|
||||
private void OnCompletionClicked(object sender, MouseButtonEventArgs e)
|
||||
{
|
||||
if (sender is not FrameworkElement { DataContext: string picked }
|
||||
|| picked.Length == 0
|
||||
|| FindRowOwner(sender) is not PropertyRowViewModel row)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (row is ViewModels.Inspector.TagPickerRowViewModel tagRow)
|
||||
{
|
||||
tagRow.EndTyping();
|
||||
}
|
||||
row.ValueText = picked;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>이 요소가 속한 행 VM — 팝업 안이라 시각 트리가 본체와 끊겨 논리 부모로도 올라간다</summary>
|
||||
private static PropertyRowViewModel? FindRowOwner(object node)
|
||||
{
|
||||
var current = node as DependencyObject;
|
||||
for (var i = 0; i < 40 && current is not null; i++)
|
||||
{
|
||||
if (current is FrameworkElement { DataContext: PropertyRowViewModel row })
|
||||
{
|
||||
return row;
|
||||
}
|
||||
current = VisualParentOf(current);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>Ctrl+1/2/3 이나 F4 로 인스펙터에 들어올 때 첫 편집 칸을 잡아 준다</summary>
|
||||
public void FocusFirstRow()
|
||||
{
|
||||
if (RowHost.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
RowHost.MoveFocus(new TraversalRequest(FocusNavigationDirection.First));
|
||||
}
|
||||
|
||||
private bool IsDescendantOfRows(DependencyObject node)
|
||||
{
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
if (ReferenceEquals(node, RowHost))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
var parent = VisualParentOf(node);
|
||||
if (parent is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>이 요소가 속한 행의 라벨 — 행 VM 을 DataContext 로 갖는 조상을 찾는다</summary>
|
||||
private static string? RowLabelOf(DependencyObject node)
|
||||
{
|
||||
for (var i = 0; i < 40; i++)
|
||||
{
|
||||
if (node is FrameworkElement { DataContext: PropertyRowViewModel row })
|
||||
{
|
||||
return row.Label;
|
||||
}
|
||||
var parent = VisualParentOf(node);
|
||||
if (parent is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
node = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private FrameworkElement? FindRowByLabel(string label)
|
||||
{
|
||||
foreach (var item in RowHost.Items)
|
||||
{
|
||||
if (item is not PropertyRowViewModel row || !string.Equals(row.Label, label, StringComparison.Ordinal))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (RowHost.ItemContainerGenerator.ContainerFromItem(row) is FrameworkElement container)
|
||||
{
|
||||
return container;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>ContentPresenter 는 시각 부모만 갖는 경우가 있어 논리 부모로도 올라간다</summary>
|
||||
private static DependencyObject? VisualParentOf(DependencyObject node)
|
||||
=> node is Visual or System.Windows.Media.Media3D.Visual3D
|
||||
? VisualTreeHelper.GetParent(node)
|
||||
: LogicalTreeHelper.GetParent(node);
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -283,13 +283,41 @@
|
||||
<ToggleButton DockPanel.Dock="Right" Style="{StaticResource LayerRowToggle}"
|
||||
IsChecked="{Binding IsHiddenFlag}" ToolTip="숨김"
|
||||
Content="{Binding HiddenIconSpec, Converter={StaticResource IconSpec}}"/>
|
||||
<!--
|
||||
컨테이너의 자식 수 — 접혀 있어도 무엇이 딸려 있는지 보여야 한다.
|
||||
페이지 행과 그룹 폴더에는 이미 개수가 붙는데 컨테이너만 없었다.
|
||||
중첩까지 세므로 "이걸 지우면 몇 개가 같이 지워지나"가 그대로 읽힌다.
|
||||
-->
|
||||
<TextBlock DockPanel.Dock="Right" VerticalAlignment="Center" FontSize="11"
|
||||
Margin="4,0,2,0" Text="{Binding LayerChildCountText}"
|
||||
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Style.Triggers>
|
||||
<!-- 선택 밴드 위에서 B.Muted 는 1.03:1 — 개수가 통째로 사라진다 -->
|
||||
<DataTrigger Binding="{Binding Highlight}" Value="Blue">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.OnSelMuted}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<!-- 계층 들여쓰기: 페이지 직속 12, 그룹 멤버 32, 컨테이너 자식 +16 -->
|
||||
<Border DockPanel.Dock="Left" Width="{Binding LayerIndent}"/>
|
||||
<!-- 컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다 -->
|
||||
<ToggleButton DockPanel.Dock="Left" Style="{StaticResource MiniChevron}" Width="16"
|
||||
<!--
|
||||
컨테이너(패널/그룹박스)만 자식 접기 셰브론을 갖는다.
|
||||
<b>없을 때도 16px 칸은 남긴다.</b> 전에는 셰브론이 Collapsed 로 사라져
|
||||
폭 16 이 통째로 없어졌는데, 컨테이너 자식 들여쓰기도 정확히 16 이라
|
||||
<b>부모와 자식의 아이콘이 같은 x 에 놓였다</b> — 목록만 보면 형제로 읽힌다.
|
||||
계층을 나타내는 유일한 수단인 들여쓰기가 셰브론 폭에 정확히 상쇄되고 있었다.
|
||||
-->
|
||||
<Border DockPanel.Dock="Left" Width="16">
|
||||
<ToggleButton Style="{StaticResource MiniChevron}" Width="16"
|
||||
IsChecked="{Binding IsLayerExpanded}" ToolTip="자식 접기/펼치기"
|
||||
Visibility="{Binding HasLayerChildren, Converter={StaticResource BoolToVisibility}}"
|
||||
Content="{Binding LayerChevronSpec, Converter={StaticResource IconSpec}}"/>
|
||||
</Border>
|
||||
<ContentControl DockPanel.Dock="Left" Margin="0,0,7,0" VerticalAlignment="Center" IsTabStop="False"
|
||||
Content="{Binding LayerIconSpec, Converter={StaticResource IconSpec}}"/>
|
||||
<!-- 데이터 배선 배지 — 값이 조회/데이터소스에서 오는 컨트롤 -->
|
||||
@@ -321,10 +349,16 @@
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding LayerId}" FontSize="10.5" Margin="0,1,0,0"
|
||||
<!--
|
||||
둘째 줄은 <b>항상</b> 있다 — 없으면 목록에 1줄 행과 2줄 행이 섞여
|
||||
훑을 때 눈이 매번 자리를 다시 잡아야 한다.
|
||||
타입명을 앞에 두는 이유: 아이콘만으로 안 갈리는 짝이 실제로 있다
|
||||
(라벨·마스크입력이 같은 글리프, 리스트박스·체크리스트도 같다).
|
||||
이름(Id)은 표시명과 다를 때만 뒤에 붙는다 — 외부 참조 키라 계속 보여야 한다.
|
||||
-->
|
||||
<TextBlock Text="{Binding LayerSubText}" FontSize="10.5" Margin="0,1,0,0"
|
||||
TextTrimming="CharacterEllipsis"
|
||||
ToolTip="컨트롤 이름 — 다른 속성이 이 이름으로 참조합니다"
|
||||
Visibility="{Binding HasLayerId, Converter={StaticResource BoolToVisibility}}">
|
||||
ToolTip="{Binding LayerSubText}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
|
||||
@@ -103,6 +103,8 @@ public partial class LayerPanelView : System.Windows.Controls.UserControl
|
||||
case System.Windows.Input.Key.X when ctrl: designer.CutSelection(); break;
|
||||
case System.Windows.Input.Key.V when ctrl: designer.Paste(); break;
|
||||
case System.Windows.Input.Key.D when ctrl: designer.CopySelection(); designer.Paste(); break;
|
||||
// 캔버스와 같은 짝 — 레이어 목록에 포커스가 있을 때도 Ctrl+Shift+Z 는 다시 실행이다
|
||||
case System.Windows.Input.Key.Z when ctrl && shift: designer.Undo.Redo(); break;
|
||||
case System.Windows.Input.Key.Z when ctrl: designer.Undo.Undo(); break;
|
||||
case System.Windows.Input.Key.Y when ctrl: designer.Undo.Redo(); break;
|
||||
case System.Windows.Input.Key.Delete: designer.DeleteSelection(); break;
|
||||
|
||||
@@ -4,18 +4,18 @@
|
||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||
Title="타입으로 거르기" Width="380" Height="520"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<Window.Resources>
|
||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||
<ctl:TypeToIconConverter x:Key="TypeToIcon"/>
|
||||
</Window.Resources>
|
||||
<DockPanel Margin="12">
|
||||
<DockPanel Margin="16">
|
||||
<TextBlock DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}" TextWrapping="Wrap" Margin="0,0,0,8"
|
||||
Text="이 페이지에 실제로 있는 타입만 보여 줍니다. 아무것도 고르지 않으면 전체가 표시됩니다."/>
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
|
||||
<Button Content="전체 해제" Padding="12,4" Click="OnClearAll"/>
|
||||
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
|
||||
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
WindowStyle="None" WindowStartupLocation="CenterScreen"
|
||||
Background="{DynamicResource B.AppBg}" FontFamily="Malgun Gothic" FontSize="13"
|
||||
Loaded="OnWindowLoaded" StateChanged="OnWindowStateChanged" Closing="OnWindowClosing"
|
||||
PreviewKeyDown="OnWindowPreviewKeyDown"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
|
||||
<!--
|
||||
@@ -24,12 +25,32 @@
|
||||
처리하며, 여기 올리면 인스펙터·검색 상자 등 TextBox 의 표준 편집 키와 충돌한다.
|
||||
-->
|
||||
<Window.InputBindings>
|
||||
<KeyBinding Key="N" Modifiers="Control" Command="{Binding NewFileCommand}"/>
|
||||
<KeyBinding Key="O" Modifiers="Control" Command="{Binding OpenFileCommand}"/>
|
||||
<!-- Ctrl+O 는 코드비하인드에서 서식 목록으로 보낸다(파일 대화상자가 아니다) -->
|
||||
<KeyBinding Key="S" Modifiers="Control" Command="{Binding SaveFileCommand}"/>
|
||||
<KeyBinding Key="S" Modifiers="Control+Shift" Command="{Binding SaveAsFileCommand}"/>
|
||||
<KeyBinding Key="P" Modifiers="Control" Command="{Binding PrintCommand}"/>
|
||||
<KeyBinding Key="W" Modifiers="Control" Command="{Binding CloseDocumentCommand}"/>
|
||||
<!--
|
||||
줌 — 커맨드 넷은 처음부터 있었고 <b>키만 없었다</b>. 8pt 글자를 확인하려 200% 로 올렸다가
|
||||
전체 배치를 보려면 캔버스에서 손을 떼고 화면 맨 아래 가운데 플로팅 바의 배율 버튼을 눌러
|
||||
팝업을 열고 '화면 맞춤'을 골라야 했다(2단계 + 마우스 왕복). 확대·축소조차 Ctrl+휠뿐이라
|
||||
트랙패드에서는 배율을 바꿀 키보드 수단이 아예 없었다.
|
||||
|
||||
반드시 창 InputBindings 여야 한다 — 캔버스 키 처리기는 포커스가 TextBox/ComboBox 면
|
||||
통째로 비켜서므로, 인스펙터 값을 고친 직후에는 줌 키가 죽는다.
|
||||
-->
|
||||
<KeyBinding Key="D0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
|
||||
<KeyBinding Key="NumPad0" Modifiers="Control" Command="{Binding ZoomResetCommand}"/>
|
||||
<KeyBinding Key="D1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
|
||||
<KeyBinding Key="NumPad1" Modifiers="Control" Command="{Binding ZoomFitCommand}"/>
|
||||
<KeyBinding Key="OemPlus" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
|
||||
<KeyBinding Key="Add" Modifiers="Control" Command="{Binding ZoomInCommand}"/>
|
||||
<KeyBinding Key="OemMinus" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
|
||||
<KeyBinding Key="Subtract" Modifiers="Control" Command="{Binding ZoomOutCommand}"/>
|
||||
<!-- 좌우 패널 접기(Ctrl+\)는 코드비하인드에서 다룬다 — 열 너비를 직접 만지는 일이라
|
||||
뷰모델이 알 필요가 없다(F4 와 같은 규범) -->
|
||||
<!-- F4 로 인스펙터 첫 칸으로 들어간다. 전에는 인스펙터로 포커스를 보내는 키가 저장소에
|
||||
하나도 없어서, 캔버스에서 값을 고치려면 Tab 으로 좌측 패널·문서 탭·플로팅 바를 다 지나야 했다. -->
|
||||
<!-- F4 는 코드비하인드에서 다룬다 — 커맨드로 하면 뷰모델이 뷰의 포커스를 알아야 한다 -->
|
||||
</Window.InputBindings>
|
||||
|
||||
<!-- GlassFrameThickness 0,0,0,1: DWM 창 그림자 활성화([200]SheetMe 크롬과 동일) -->
|
||||
@@ -151,18 +172,31 @@
|
||||
Content="{Binding Source=chevron-down, Converter={StaticResource IconName}, ConverterParameter=11}"/>
|
||||
</StackPanel>
|
||||
</MenuItem.Header>
|
||||
<!--
|
||||
파일 경로를 메뉴에서 뺐다 — 열기·저장은 DB 만 쓴다.
|
||||
운영 서식 1,271건이 전부 DB 에 있고, 파일로 저장한 XML 은 EMR 이 읽지 않는다.
|
||||
'DB에서 열기' 대화상자도 지웠다 — 좌측 '서식 목록' 탭이 같은 조회를 상시로 한다
|
||||
(그 창이 덮고 있던 구멍 넷을 좌측에 먼저 옮겼다: 저장 후 재조회 · 서식명 툴팁 ·
|
||||
디자인 없음/사용 꺼짐 구분 · Enter 로 열기).
|
||||
파일 저장·열기 코드는 남아 있다 — 접속 없이 도는 진단(render-smoke,
|
||||
edit-smoke 직렬화 왕복)이 그 경로를 쓴다. UI 진입점만 없앤 것이다.
|
||||
-->
|
||||
<MenuItem Header="파일(_F)">
|
||||
<MenuItem Header="새 서식(_N)" Command="{Binding NewFileCommand}" InputGestureText="Ctrl+N"/>
|
||||
<MenuItem Header="열기(_O)..." Command="{Binding OpenFileCommand}" InputGestureText="Ctrl+O"/>
|
||||
<MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="저장(_S)" Command="{Binding SaveFileCommand}" InputGestureText="Ctrl+S"/>
|
||||
<MenuItem Header="다른 이름으로 저장(_A)..." Command="{Binding SaveAsFileCommand}" InputGestureText="Ctrl+Shift+S"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="DB에서 열기(_D)..." Command="{Binding OpenFromDbCommand}"/>
|
||||
<MenuItem Header="DB에 저장(_B)..." Command="{Binding SaveToDbCommand}"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="JSON 가져오기(_I)..." Command="{Binding ImportJsonCommand}"/>
|
||||
<MenuItem Header="JSON 내보내기(_E)..." Command="{Binding ExportJsonCommand}"/>
|
||||
<MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S"
|
||||
IsEnabled="{Binding CanSaveToDb}"
|
||||
ToolTip="새 버전을 만듭니다. 지금 버전은 이력으로 남습니다"/>
|
||||
<!--
|
||||
수정 — 레거시 '기록지 수정'. 저장과 다른 동작이라 따로 둔다.
|
||||
저장은 새 버전을 만들어 그것이 활성이 되고, 수정은 지금 쓰이는 버전을
|
||||
그 자리에서 고친다(SdgKey 유지). E_SctMst 를 안 건드리므로 글자만 고칠 수 있고,
|
||||
새 컨트롤이 있으면 커맨드가 막는다.
|
||||
레거시처럼 전산실에만 열어 둔다 — 저장 버튼에는 그런 분기가 없다.
|
||||
-->
|
||||
<MenuItem Header="수정(_M) — 지금 버전을 제자리에서" Command="{Binding ModifyInDbCommand}"
|
||||
IsEnabled="{Binding CanModifyInDb}"
|
||||
ToolTip="새 버전을 만들지 않고 지금 버전의 글자만 고칩니다 (전산실 전용)"/>
|
||||
<Separator/>
|
||||
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
|
||||
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
|
||||
@@ -279,6 +313,14 @@
|
||||
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="다크/라이트 전환"
|
||||
Click="OnThemeToggleClick"
|
||||
Content="{Binding Source=sun, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<!--
|
||||
환자 선택 — 앱 전체에 적용된다. 전에는 미리보기 창 안에만 있어서
|
||||
쿼리 편집처럼 환자 값이 필요한 다른 화면에서 고를 수가 없었다.
|
||||
여기서 한 번 고르면 PatientSession 하나를 모든 화면이 본다.
|
||||
고른 상태는 <b>아이콘 색</b>으로 알린다 — 늘 보이는 자리라 글자를 둘 폭이 없다.
|
||||
-->
|
||||
<Button x:Name="PatientIconButton" Style="{StaticResource Subtle}"
|
||||
Width="34" Height="28" Padding="0" Click="OnPickPatientClick"/>
|
||||
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0" ToolTip="DB에 저장"
|
||||
Command="{Binding SaveToDbCommand}"
|
||||
Content="{Binding Source=save, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
@@ -314,13 +356,13 @@
|
||||
<Grid.ColumnDefinitions>
|
||||
<!-- [200] 기본 폭 — 좌 248(레이어 행에 타입 아이콘 + 토글 2개가 들어가 232 로는 이름이 잘린다),
|
||||
우 300(인스펙터 X|Y 쌍이 한 줄에 들어가는 최소치) -->
|
||||
<ColumnDefinition Width="248" MinWidth="180" MaxWidth="480"/>
|
||||
<ColumnDefinition x:Name="LeftColumn" Width="248" MinWidth="180" MaxWidth="480"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<!-- 최소 250 — 글꼴 스타일 행이 라벨 92 + 토글 4칸 128 = 220px 를 요구한다
|
||||
(행 가용폭 = 폭 − 스크롤바 9 − 여백 20). 220 이면 마지막 토글이 잘린다. -->
|
||||
<ColumnDefinition Width="300" MinWidth="250" MaxWidth="560"/>
|
||||
<ColumnDefinition x:Name="RightColumn" Width="300" MinWidth="250" MaxWidth="560"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<!-- 좌: 알약 탭 — 서식 목록 / 레이어 / 도구 상자 -->
|
||||
@@ -344,6 +386,7 @@
|
||||
<!-- 자리표시자는 TextBox 위에 겹친 TextBlock — Text 가 비었을 때만 보인다 -->
|
||||
<Grid>
|
||||
<TextBox x:Name="SheetSearchBox"
|
||||
PreviewKeyDown="OnSheetSearchKeyDown"
|
||||
Text="{Binding SheetSearchKeyword, UpdateSourceTrigger=PropertyChanged}">
|
||||
<TextBox.InputBindings>
|
||||
<KeyBinding Key="Enter" Command="{Binding SearchSheetsCommand}"/>
|
||||
@@ -380,7 +423,8 @@
|
||||
VirtualizingPanel.VirtualizationMode="Recycling"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
MouseDoubleClick="OnSheetListDoubleClick"
|
||||
ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭해 엽니다">
|
||||
PreviewKeyDown="OnSheetListKeyDown"
|
||||
ToolTip="분류 머리글을 눌러 펼치고, 서식을 더블클릭하거나 Enter 로 엽니다">
|
||||
<ListBox.GroupStyle>
|
||||
<GroupStyle>
|
||||
<GroupStyle.ContainerStyle>
|
||||
@@ -460,17 +504,28 @@
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
|
||||
<!--
|
||||
서식명 툴팁 — 폭이 148px 남짓(자물쇠가 붙으면 134, 패널 최소폭이면 80)이라
|
||||
긴 이름은 말줄임표로 잘리는데 전문을 볼 방법이 없었다.
|
||||
DB에서 열기 대화상자는 열을 300px 로 주고 드래그로 늘릴 수 있었다 —
|
||||
그 창을 지우기 전에 여기로 옮긴다.
|
||||
'디자인 없음'과 '서식생성기 사용 꺼짐'을 툴팁으로 갈라 준다.
|
||||
전에는 둘 다 같은 회색+0.6 이라 화면만 보고는 구분할 수 없었다.
|
||||
-->
|
||||
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis"
|
||||
ToolTip="{Binding Name}">
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding HasDesign}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Opacity" Value="0.6"/>
|
||||
<Setter Property="ToolTip" Value="아직 디자인이 없는 서식입니다 — 열면 빈 종이로 시작합니다"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Setter Property="Opacity" Value="0.6"/>
|
||||
<Setter Property="ToolTip" Value="기록지 정보에서 서식생성기 사용이 꺼져 있습니다 — 열 수 없습니다"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
@@ -563,6 +618,16 @@
|
||||
</ContentControl>
|
||||
<TextBlock Text="{Binding DisplayName}" MaxWidth="170" Style="{StaticResource TabHeaderText}"
|
||||
TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
|
||||
<!--
|
||||
저장 안 됨 표시. 상태(UndoService.IsDirty)는 저장 지점 깊이까지 추적해
|
||||
정교하게 있었는데 화면에 한 번도 나오지 않았다 — 서식 셋을 열어 둘을 고치면
|
||||
어느 것이 안 저장됐는지 알 방법이 없어, 안전하게 전부 다시 저장하게 되고
|
||||
DB 저장은 그때마다 새 버전을 만든다.
|
||||
-->
|
||||
<Ellipse Width="6" Height="6" Margin="6,0,0,0" VerticalAlignment="Center"
|
||||
Fill="{DynamicResource B.Accent}"
|
||||
ToolTip="저장하지 않은 변경이 있습니다"
|
||||
Visibility="{Binding IsDirty, Converter={StaticResource BoolToVisibility}}"/>
|
||||
<Button Content="✕" FontSize="11" Margin="7,0,0,0"
|
||||
Style="{StaticResource Subtle}" Padding="3,0" Cursor="Hand" ToolTip="문서 닫기"
|
||||
Command="{Binding DataContext.CloseDocumentCommand,
|
||||
@@ -626,8 +691,11 @@
|
||||
<Border.Effect>
|
||||
<DynamicResource ResourceKey="B.FloatShadow"/>
|
||||
</Border.Effect>
|
||||
<!-- 힌트는 ScrollViewer <b>안쪽</b>이어야 한다 — ScrollViewer 도 중간 서피스를 열어
|
||||
바깥에 걸면 무효다(진단 cleartype 의 I/J 표본이 그 차이다). -->
|
||||
<ScrollViewer MaxHeight="440" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10">
|
||||
<ItemsControl ItemsSource="{Binding Source={StaticResource PaletteGrouped}}" Margin="10,9,10,10"
|
||||
Background="{DynamicResource B.Panel}" RenderOptions.ClearTypeHint="Enabled">
|
||||
<!-- 그룹 세로 나열 + 그룹 내 타일 4열([200] 플라이아웃 치수) -->
|
||||
<ItemsControl.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
@@ -672,7 +740,8 @@
|
||||
<Border.Effect>
|
||||
<DynamicResource ResourceKey="B.FloatShadow"/>
|
||||
</Border.Effect>
|
||||
<StackPanel Margin="5">
|
||||
<StackPanel Margin="5" Background="{DynamicResource B.Panel}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<Button Style="{StaticResource FlyoutRow}" Command="{Binding ZoomInCommand}" Click="OnZoomRowClick">
|
||||
<DockPanel LastChildFill="True">
|
||||
<TextBlock DockPanel.Dock="Right" Text="Ctrl+휠↑" Foreground="{DynamicResource B.Muted}"
|
||||
@@ -718,12 +787,10 @@
|
||||
HorizontalAlignment="Center" Margin="0,16,0,6"/>
|
||||
<TextBlock Foreground="{DynamicResource B.Muted}" FontSize="12.5" TextAlignment="Center"
|
||||
TextWrapping="Wrap" HorizontalAlignment="Center" Margin="0,0,0,20"
|
||||
Text="새 서식을 만들거나, 왼쪽 서식 목록에서 기존 서식을 두 번 눌러 엽니다."/>
|
||||
Text="왼쪽 서식 목록에서 서식을 두 번 눌러 엽니다. (F4 또는 Ctrl+O)"/>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center">
|
||||
<Button Content="새 서식" Command="{Binding NewFileCommand}" Padding="16,7" Margin="0,0,8,0"/>
|
||||
<Button Content="DB에서 열기" Command="{Binding OpenFromDbCommand}" Padding="16,7"
|
||||
<Button Content="서식 목록에서 열기" Click="OnOpenFromListClick" Padding="16,7"
|
||||
IsEnabled="{Binding CanUseDb}"/>
|
||||
<Button Content="파일 열기" Command="{Binding OpenFileCommand}" Padding="16,7" Margin="8,0,0,0"/>
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</Border>
|
||||
@@ -738,7 +805,17 @@
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="0,0,0,1">
|
||||
<TextBlock Text="속성" FontWeight="Bold" Foreground="{DynamicResource B.Muted}"/>
|
||||
</Border>
|
||||
<v:InspectorView DataContext="{Binding CurrentDesigner.Inspector}"/>
|
||||
<!--
|
||||
열린 서식이 없으면 인스펙터를 <b>접는다.</b>
|
||||
DataContext 가 null 이면 안쪽 Visibility 바인딩이 전부 실패하고,
|
||||
WPF 는 실패한 바인딩을 기본값(Visible)으로 떨어뜨린다 —
|
||||
그래서 선택이 없는데도 정렬 바와 간격값이 남아 있었다.
|
||||
'새 서식'을 없애면서 시작 화면에 문서가 아예 없어졌고, 그 자리가 드러났다.
|
||||
-->
|
||||
<v:InspectorView x:Name="Inspector" DataContext="{Binding CurrentDesigner.Inspector}"
|
||||
Visibility="{Binding DataContext.HasOpenDocuments,
|
||||
RelativeSource={RelativeSource AncestorType=Window},
|
||||
Converter={StaticResource BoolToVisibility}}"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
</Grid>
|
||||
|
||||
@@ -14,9 +14,25 @@ public partial class MainView : Window
|
||||
public MainView()
|
||||
{
|
||||
InitializeComponent();
|
||||
// 환자는 앱 전체가 하나를 공유한다 — 어느 화면에서 바뀌어도 이 아이콘이 따라와야 한다.
|
||||
// 창이 살아 있는 동안만 듣는다(정적 이벤트라 떼지 않으면 창이 회수되지 않는다).
|
||||
RefreshPatientIcon();
|
||||
PatientSession.Changed += OnPatientSessionChanged;
|
||||
Closed += (_, _) => PatientSession.Changed -= OnPatientSessionChanged;
|
||||
|
||||
// StaysOpen=False 팝업은 바깥 클릭(버튼 포함)으로 먼저 닫힌다 — 토글 버튼 재클릭이 곧바로 다시 열지 않게 닫힌 시각 기록
|
||||
AddControlPopup.Closed += (_, _) => addControlPopupClosedAt = Environment.TickCount;
|
||||
ZoomPopup.Closed += (_, _) => zoomPopupClosedAt = Environment.TickCount;
|
||||
|
||||
// 진단(게이트)은 창 크기를 스스로 정한다 — 저장된 취향이 끼어들면 샷 크기가 PC 마다 달라진다
|
||||
if (StartupArguments.DiagnosticMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
// Show 전에 복원해야 한 프레임 번쩍이지 않는다
|
||||
WindowPlacement.Restore(this);
|
||||
LeftColumn.Width = new GridLength(WindowPlacement.LeftPanelWidth(LeftColumn.Width.Value));
|
||||
RightColumn.Width = new GridLength(WindowPlacement.RightPanelWidth(RightColumn.Width.Value));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -36,6 +52,12 @@ public partial class MainView : Window
|
||||
private void OnWindowLoaded(object sender, RoutedEventArgs e)
|
||||
{
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
// 진단(게이트) 실행에서는 Loaded 커맨드를 돌리지 않는다 — 서식 목록 DB 조회가 붙어 있어서
|
||||
// 검사 결과가 접속 상태와 조회 결과에 따라 달라진다. 게이트는 DB 없이 같은 답을 내야 한다.
|
||||
if (StartupArguments.DiagnosticMode)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (DataContext is MainViewModel viewModel && viewModel.LoadedCommand?.CanExecute(null) == true)
|
||||
{
|
||||
viewModel.LoadedCommand.Execute(null);
|
||||
@@ -57,6 +79,73 @@ public partial class MainView : Window
|
||||
ThemeMenuItem.IsChecked = !ThemeManager.IsLight;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 환자 선택 — 여기서 고르면 <b>앱 전체</b>가 그 환자를 본다.
|
||||
///
|
||||
/// 안 골랐으면 바로 선택 창을 연다. 이미 골랐으면 <b>메뉴</b>를 띄운다 —
|
||||
/// 늘 보이는 아이콘이라 "누르면 해제"로 두면 실수로 지운다.
|
||||
/// 바꾸기·해제를 글자로 보여 주고 고르게 하는 쪽이 예측 가능하다.
|
||||
/// </summary>
|
||||
private void OnPickPatientClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (!PatientSession.HasPatient)
|
||||
{
|
||||
Pick();
|
||||
return;
|
||||
}
|
||||
var menu = new System.Windows.Controls.ContextMenu
|
||||
{
|
||||
PlacementTarget = PatientIconButton,
|
||||
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
|
||||
};
|
||||
var header = new System.Windows.Controls.MenuItem
|
||||
{
|
||||
Header = PatientSession.Label,
|
||||
IsEnabled = false,
|
||||
};
|
||||
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
|
||||
change.Click += (_, _) => Pick();
|
||||
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
|
||||
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
|
||||
menu.Items.Add(header);
|
||||
menu.Items.Add(new System.Windows.Controls.Separator());
|
||||
menu.Items.Add(change);
|
||||
menu.Items.Add(clear);
|
||||
menu.IsOpen = true;
|
||||
|
||||
void Pick()
|
||||
{
|
||||
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다
|
||||
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
|
||||
{
|
||||
PatientSession.Set(picked, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 아이콘이 지금 상태를 말한다 — 늘 보이는 자리라 글자를 둘 폭이 없어 <b>색</b>으로 알린다.
|
||||
/// 고른 환자 이름은 툴팁에 둔다(제목줄에 환자 이름을 상시 노출하지 않는다).
|
||||
/// </summary>
|
||||
private void OnPatientSessionChanged(object? sender, EventArgs e) => RefreshPatientIcon();
|
||||
|
||||
private void RefreshPatientIcon()
|
||||
{
|
||||
if (PatientIconButton is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
var on = PatientSession.HasPatient;
|
||||
// 안 고른 상태의 색은 <b>B.Muted</b> 다 — 옆 아이콘들(복사·삭제·테마·저장)이 XAML 컨버터의
|
||||
// 기본값으로 쓰는 바로 그 키다. 전에는 null 을 넘겼고, 그러면 아이콘이 테마 토큰이 아니라
|
||||
// 하드코딩 검정으로 칠해져 어두운 제목줄에서 <b>혼자만 까맣게</b> 묻혔다.
|
||||
// (컴파일러가 CS8604 로 계속 경고하고 있었는데 그 경고가 가리키던 것이 이것이다.)
|
||||
PatientIconButton.Content = Controls.LucideIcons.Icon("user", 16, on ? "B.Accent" : "B.Muted");
|
||||
PatientIconButton.ToolTip = on
|
||||
? $"환자: {PatientSession.Label}\n누르면 바꾸기·해제"
|
||||
: "환자 선택 — 태그와 MDataTable 에 실제 값이 들어갑니다(앱 전체 적용)";
|
||||
}
|
||||
|
||||
/// <summary>플로팅 바 — 컨트롤 추가 플라이아웃 토글(닫힘 직후 재클릭 가드)</summary>
|
||||
private void OnAddControlFlyoutClick(object sender, RoutedEventArgs e)
|
||||
{
|
||||
@@ -127,7 +216,146 @@ public partial class MainView : Window
|
||||
if (DataContext is MainViewModel viewModel && !viewModel.IsShuttingDown && !viewModel.ConfirmCloseAll())
|
||||
{
|
||||
e.Cancel = true;
|
||||
return;
|
||||
}
|
||||
// 취소되지 않은 뒤에만 저장한다 — 닫기를 물렀는데 크기가 기억되면 조작하지 않은 값이 남는다
|
||||
if (!StartupArguments.DiagnosticMode)
|
||||
{
|
||||
WindowPlacement.Save(this, LeftColumn.ActualWidth, RightColumn.ActualWidth);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// F4 — 인스펙터 첫 편집 칸으로 들어간다.
|
||||
///
|
||||
/// 전에는 인스펙터로 포커스를 보내는 키가 저장소에 <b>하나도 없었다</b>.
|
||||
/// 캔버스에서 Tab 을 누르면 좌측 패널·문서 탭·플로팅 바를 다 지나야 인스펙터에 닿았다.
|
||||
/// 커맨드로 만들지 않는다 — 그러면 뷰모델이 뷰의 포커스를 알아야 한다.
|
||||
/// </summary>
|
||||
private void OnWindowPreviewKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == System.Windows.Input.Key.F4)
|
||||
{
|
||||
// 인스펙터가 접혀 있으면 먼저 편다 — 안 그러면 보이지도 않는 칸으로 포커스가 들어간다
|
||||
if (panelsCollapsed)
|
||||
{
|
||||
TogglePanels();
|
||||
}
|
||||
Inspector.FocusFirstRow();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
// Ctrl+\ — 좌우 패널을 함께 접었다 편다('종이만 보기').
|
||||
// 개별 토글 두 개를 두지 않는다: 목적이 하나라 키도 하나여야 손이 기억한다.
|
||||
if (e.Key is System.Windows.Input.Key.OemBackslash or System.Windows.Input.Key.Oem5
|
||||
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
|
||||
{
|
||||
TogglePanels();
|
||||
e.Handled = true;
|
||||
return;
|
||||
}
|
||||
// Ctrl+O — 서식 목록으로. 전에는 OS 파일 대화상자로 갔다.
|
||||
// 손이 먼저 가는 키인데 디스크로 가고, 운영 서식 1,271건은 전부 DB 에 있다.
|
||||
if (e.Key == System.Windows.Input.Key.O
|
||||
&& System.Windows.Input.Keyboard.Modifiers == System.Windows.Input.ModifierKeys.Control)
|
||||
{
|
||||
FocusSheetList();
|
||||
e.Handled = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>지금 좌우 패널이 접혀 있는가 — 접힌 폭을 '사용자가 정한 폭'으로 오해하지 않기 위한 상태</summary>
|
||||
private bool panelsCollapsed;
|
||||
|
||||
/// <summary>펼쳤을 때의 폭 — 접는 순간 기억해 두었다가 그대로 되돌린다</summary>
|
||||
private GridLength expandedLeft;
|
||||
private GridLength expandedRight;
|
||||
private double expandedLeftMin;
|
||||
private double expandedRightMin;
|
||||
|
||||
/// <summary>
|
||||
/// 좌우 패널을 함께 접었다 편다 — <b>종이만 보기</b>.
|
||||
///
|
||||
/// 좌 248 + 스플리터 5 + 스플리터 5 + 우 300 = 558px 가 <b>항상</b> 점유된다.
|
||||
/// MinWidth 180/250 때문에 스플리터를 끝까지 밀어도 사라지지 않고, 보기 메뉴에도 항목이 없었다.
|
||||
/// 가로 용지를 120% 로만 올려도 캔버스에 가로 스크롤이 생기는데 '종이만 보기' 상태가 없었다.
|
||||
///
|
||||
/// <b>MinWidth 도 함께 0 으로 내려야</b> 실제로 사라진다 — Width=0 만으로는 최소폭이 이긴다.
|
||||
/// 되돌릴 때 쓰려고 펼친 폭을 따로 기억한다(접힌 0 을 사용자가 정한 폭으로 착각하면 안 된다).
|
||||
/// </summary>
|
||||
private void TogglePanels()
|
||||
{
|
||||
if (panelsCollapsed)
|
||||
{
|
||||
LeftColumn.MinWidth = expandedLeftMin;
|
||||
RightColumn.MinWidth = expandedRightMin;
|
||||
LeftColumn.Width = expandedLeft;
|
||||
RightColumn.Width = expandedRight;
|
||||
panelsCollapsed = false;
|
||||
return;
|
||||
}
|
||||
expandedLeft = LeftColumn.Width;
|
||||
expandedRight = RightColumn.Width;
|
||||
expandedLeftMin = LeftColumn.MinWidth;
|
||||
expandedRightMin = RightColumn.MinWidth;
|
||||
LeftColumn.MinWidth = 0;
|
||||
RightColumn.MinWidth = 0;
|
||||
LeftColumn.Width = new GridLength(0);
|
||||
RightColumn.Width = new GridLength(0);
|
||||
panelsCollapsed = true;
|
||||
}
|
||||
|
||||
/// <summary>파일 메뉴·빈 화면 버튼에서 서식 목록으로 보낸다</summary>
|
||||
private void OnOpenFromListClick(object sender, RoutedEventArgs e) => FocusSheetList();
|
||||
|
||||
/// <summary>서식 목록 탭으로 옮기고 검색칸에 커서를 둔다</summary>
|
||||
private void FocusSheetList()
|
||||
{
|
||||
if (DataContext is MainViewModel viewModel)
|
||||
{
|
||||
viewModel.SelectedLeftTabIndex = 0;
|
||||
}
|
||||
// 탭이 방금 바뀌어 아직 시각 트리가 없다 — 레이아웃이 한 번 돈 뒤에 잡는다
|
||||
// (LayerPanelView 의 검색칸 포커스와 같은 규범).
|
||||
Dispatcher.BeginInvoke(new Action(() =>
|
||||
{
|
||||
SheetSearchBox.Focus();
|
||||
System.Windows.Input.Keyboard.Focus(SheetSearchBox);
|
||||
SheetSearchBox.SelectAll();
|
||||
}), System.Windows.Threading.DispatcherPriority.Input);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식 목록 키 처리 — Enter 로 연다.
|
||||
///
|
||||
/// 전에는 <b>더블클릭 전용</b>이라 키보드로는 서식을 열 수 없었다.
|
||||
/// 'DB에서 열기' 대화상자에는 [열기] 버튼이 있어서 그 구멍이 가려져 있었다 — 그 창을 지우기 전에 메운다.
|
||||
/// </summary>
|
||||
private void OnSheetListKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != System.Windows.Input.Key.Enter || DataContext is not MainViewModel viewModel)
|
||||
{
|
||||
return;
|
||||
}
|
||||
viewModel.OpenSheetFromList(SheetListBox.SelectedItem as SheetSummary);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>검색칸에서 ↓ 를 누르면 목록으로 내려간다 — 검색과 선택 사이에 마우스를 두지 않는다</summary>
|
||||
private void OnSheetSearchKeyDown(object sender, System.Windows.Input.KeyEventArgs e)
|
||||
{
|
||||
if (e.Key != System.Windows.Input.Key.Down || SheetListBox.Items.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
SheetListBox.SelectedIndex = Math.Max(0, SheetListBox.SelectedIndex);
|
||||
SheetListBox.Focus();
|
||||
if (SheetListBox.ItemContainerGenerator.ContainerFromIndex(SheetListBox.SelectedIndex)
|
||||
is System.Windows.Controls.ListBoxItem item)
|
||||
{
|
||||
item.Focus();
|
||||
}
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
/// <summary>최대화/복원 글리프 전환(Segoe MDL2: E922=최대화, E923=복원)</summary>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="마스크 편집" Width="620" Height="620"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
<DockPanel Margin="12">
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<DockPanel Margin="16">
|
||||
<!--
|
||||
백슬래시를 글리프로 보여주지 않는다 — 한글 글꼴에서 원화 기호(₩)로 렌더되어 오해를 부른다.
|
||||
아래 '마스크' 입력란은 Consolas 우선이라 거기서는 백슬래시로 제대로 보인다.
|
||||
@@ -14,7 +14,7 @@
|
||||
|
||||
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
|
||||
<Button Content="마스크 없음" Padding="12,4" Click="OnClear"/>
|
||||
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="확인" Padding="20,4" Margin="8,0,0,0" Click="OnConfirm" IsDefault="True" Style="{StaticResource Primary}"/>
|
||||
<Button Content="취소" Padding="20,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
|
||||
|
||||
@@ -47,6 +47,11 @@ public partial class MessageDialogView : Window
|
||||
// 노트북 해상도에서 창이 화면을 덮지 않도록 한 번 더 조인다
|
||||
MaxHeight = Math.Min(620, SystemParameters.WorkArea.Height * 0.85);
|
||||
|
||||
// 뒤를 어둡게 할 자격 — 지금 멈추고 결정해야 하는 것만.
|
||||
// 물음(되돌릴 수 없는 쓰기의 확인)과 오류(진행이 정의되지 않는 차단)가 그것이다.
|
||||
// 알림·경고는 읽고 지나가는 것이라 뒤를 가리지 않는다.
|
||||
Services.ModalScrim.SetDimBehind(this, kind is DialogKind.Question or DialogKind.Error);
|
||||
|
||||
CaptionText.Text = caption;
|
||||
HeadText.Text = head;
|
||||
DetailText.Text = detail ?? string.Empty;
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.PatientPickerDialogView"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
Title="환자 선택 — 미리보기" Width="1040" Height="620"
|
||||
MinWidth="880" MinHeight="480"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<DockPanel Margin="16">
|
||||
|
||||
<!-- 검색 줄. 유형을 먼저 고른다 — 유형마다 일치 방식이 달라서(차트번호는 완전일치,
|
||||
성명은 접두, 휴대전화는 부분) 한 칸에 몰아넣으면 왜 안 찾아지는지 설명할 수 없다. -->
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,10">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock Grid.Column="0" Text="검색" VerticalAlignment="Center" Margin="0,0,8,0"
|
||||
Foreground="{DynamicResource B.Muted}"/>
|
||||
<ComboBox x:Name="KindCombo" Grid.Column="1" Width="110" SelectedIndex="0"
|
||||
AutomationProperties.Name="검색 유형">
|
||||
<ComboBoxItem Content="차트번호"/>
|
||||
<ComboBoxItem Content="성명"/>
|
||||
<ComboBoxItem Content="주민번호"/>
|
||||
<ComboBoxItem Content="휴대전화"/>
|
||||
</ComboBox>
|
||||
<TextBox x:Name="TermBox" Grid.Column="2" Margin="8,0" VerticalContentAlignment="Center"
|
||||
KeyDown="OnTermKeyDown" AutomationProperties.Name="검색어"/>
|
||||
<Button x:Name="SearchButton" Grid.Column="3" Content="찾기(_F)" Padding="18,4" Click="OnSearch"/>
|
||||
</Grid>
|
||||
|
||||
<!-- 감사 고지. 이 창은 실제 환자 정보를 꺼낸다 — 기록된다는 사실을 화면에서 말한다. -->
|
||||
<TextBlock DockPanel.Dock="Top" Margin="0,0,0,8" TextWrapping="Wrap"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="실제 환자 정보를 불러옵니다. 조회한 사용자와 내원번호가 감사 로그에 남습니다(환자 이름은 남기지 않습니다)."/>
|
||||
|
||||
<!-- 아래 줄. 고른 것을 글로 확인시키고 기준 시점을 함께 둔다.
|
||||
기준 시점은 입원 내원의 진료과·병실·자격 값을 실제로 바꾼다 — 숨기면 값이 왜 다른지 알 수 없다. -->
|
||||
<Grid DockPanel.Dock="Bottom" Margin="0,10,0,0">
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="*"/>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<TextBlock x:Name="ChosenText" Grid.Column="0" VerticalAlignment="Center"
|
||||
TextTrimming="CharacterEllipsis" Foreground="{DynamicResource B.Muted}"
|
||||
Text="환자를 찾아 내원을 고르세요."/>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<TextBlock Text="기준 시점" VerticalAlignment="Center" Margin="0,0,6,0"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
ToolTip="입원 내원은 구간이라 어느 시점의 진료과·병실·보험 자격을 쓸지 정해야 합니다. 외래는 접수일시로 같습니다."/>
|
||||
<ComboBox x:Name="EdgeCombo" Width="150" SelectedIndex="0"
|
||||
AutomationProperties.Name="기준 시점">
|
||||
<ComboBoxItem Content="마지막(퇴원 시점)"/>
|
||||
<ComboBoxItem Content="처음(입원 시점)"/>
|
||||
</ComboBox>
|
||||
<Button x:Name="SelectButton" Content="선택" Padding="20,4" Margin="10,0,0,0"
|
||||
Click="OnSelect" IsDefault="True" IsEnabled="False"
|
||||
Style="{StaticResource Primary}"/>
|
||||
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
|
||||
</StackPanel>
|
||||
</Grid>
|
||||
|
||||
<!-- 본문. 환자 목록과 그 환자의 내원 목록을 동시에 둔다 —
|
||||
고르는 대상은 내원이므로 환자를 고른 뒤 화면이 바뀌면 왕복이 생긴다. -->
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="47*"/>
|
||||
<ColumnDefinition Width="6"/>
|
||||
<ColumnDefinition Width="53*"/>
|
||||
</Grid.ColumnDefinitions>
|
||||
|
||||
<DockPanel Grid.Column="0">
|
||||
<TextBlock x:Name="PatientCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="환자 — 검색어를 넣고 찾기를 누르세요"/>
|
||||
<ListView x:Name="PatientList" SelectionChanged="OnPatientChanged"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
AutomationProperties.Name="환자 목록">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="차트번호" Width="90" DisplayMemberBinding="{Binding ChtNum}"/>
|
||||
<GridViewColumn Header="성명" Width="80" DisplayMemberBinding="{Binding Name}"/>
|
||||
<GridViewColumn Header="주민번호" Width="105" DisplayMemberBinding="{Binding ResNumMasked}"/>
|
||||
<GridViewColumn Header="휴대전화" Width="100" DisplayMemberBinding="{Binding MobilePhone}"/>
|
||||
<GridViewColumn Header="비고" Width="70">
|
||||
<GridViewColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock>
|
||||
<TextBlock.Style>
|
||||
<Style TargetType="TextBlock">
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||
<Style.Triggers>
|
||||
<DataTrigger Binding="{Binding Inpatient}" Value="True">
|
||||
<Setter Property="Text" Value="재원"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Accent}"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Active}" Value="False">
|
||||
<Setter Property="Text" Value="사용중지"/>
|
||||
</DataTrigger>
|
||||
<DataTrigger Binding="{Binding Shielded}" Value="True">
|
||||
<Setter Property="Text" Value="정보보호"/>
|
||||
<Setter Property="Foreground" Value="{DynamicResource B.Danger}"/>
|
||||
</DataTrigger>
|
||||
</Style.Triggers>
|
||||
</Style>
|
||||
</TextBlock.Style>
|
||||
</TextBlock>
|
||||
</DataTemplate>
|
||||
</GridViewColumn.CellTemplate>
|
||||
</GridViewColumn>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
|
||||
<GridSplitter Grid.Column="1" Width="6" HorizontalAlignment="Stretch"
|
||||
Background="Transparent"/>
|
||||
|
||||
<DockPanel Grid.Column="2">
|
||||
<TextBlock x:Name="VisitCountText" DockPanel.Dock="Top" Margin="0,0,0,4"
|
||||
Foreground="{DynamicResource B.Muted}"
|
||||
Text="내원 — 환자를 고르면 나옵니다"/>
|
||||
<ListView x:Name="VisitList" MouseDoubleClick="OnSelect"
|
||||
SelectionChanged="OnVisitChanged"
|
||||
BorderBrush="{DynamicResource B.Line}" BorderThickness="1"
|
||||
VirtualizingPanel.IsVirtualizing="True"
|
||||
AutomationProperties.Name="내원 목록">
|
||||
<ListView.View>
|
||||
<GridView>
|
||||
<GridViewColumn Header="구분" Width="46" DisplayMemberBinding="{Binding Kind}"/>
|
||||
<GridViewColumn Header="접수일시" Width="106" DisplayMemberBinding="{Binding AcceptedAtText}"/>
|
||||
<!-- 퇴원일시 — 이미 조회돼 있는데 화면에 없었다.
|
||||
입원 내원이 '구간'이라는 사실이 이 열 하나로 눈에 보인다.
|
||||
그래야 아래 기준 시점 선택이 무슨 뜻인지 알 수 있다. -->
|
||||
<GridViewColumn Header="퇴원일시" Width="106" DisplayMemberBinding="{Binding LeftAtText}"/>
|
||||
<GridViewColumn Header="진료과" Width="86" DisplayMemberBinding="{Binding Department}"/>
|
||||
<GridViewColumn Header="담당의" Width="68" DisplayMemberBinding="{Binding Doctor}"/>
|
||||
<GridViewColumn Header="보험" Width="74" DisplayMemberBinding="{Binding Insurance}"/>
|
||||
<GridViewColumn Header="상태" Width="58" DisplayMemberBinding="{Binding State}"/>
|
||||
</GridView>
|
||||
</ListView.View>
|
||||
</ListView>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</DockPanel>
|
||||
</Window>
|
||||
@@ -0,0 +1,273 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Input;
|
||||
using SheetMe.Core.Catalog;
|
||||
using SheetMe.Data.Stores;
|
||||
using SheetMe.Designer.DataBusiness;
|
||||
using SheetMe.Designer.Services;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>
|
||||
/// 미리보기에 쓸 <b>실제 환자·내원</b>을 고른다.
|
||||
///
|
||||
/// <b>왜 모달인가.</b> 30초 안에 끝나는 자족적인 일이고(찾기 → 고르기), 권한 문이 걸린 동작이다.
|
||||
/// 사이드 패널로 두면 미리보기 종이와 나란히 놓여 항상 열려 있게 되는데,
|
||||
/// 이 창은 실제 환자 정보를 꺼내는 창이라 <b>열려 있는 것 자체가 비용</b>이다.
|
||||
///
|
||||
/// <b>왜 두 목록을 한 화면에 두는가.</b> 고르는 대상은 환자가 아니라 <b>내원</b>이다.
|
||||
/// 환자 문맥 5행이 전부 내원번호와 적용일시로 읽히므로(<see cref="PatientContextStore"/>)
|
||||
/// 환자만 골라서는 아무것도 읽을 수 없다. 환자를 고른 뒤 화면이 바뀌면 "이 사람 맞나"를
|
||||
/// 다시 확인하러 왕복해야 한다.
|
||||
///
|
||||
/// <b>문맥을 여기서 읽는다.</b> 창을 닫고 나서 읽으면 실패를 미리보기 창에서 알려야 하고,
|
||||
/// 그때는 이미 고른 것이 사라진 뒤다. 여기서 읽어 실패하면 창을 닫지 않는다.
|
||||
/// </summary>
|
||||
public partial class PatientPickerDialogView : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly PatientVisitStore? store;
|
||||
private readonly PatientContextStore? contexts;
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PatientPickerDialogView()
|
||||
{
|
||||
InitializeComponent();
|
||||
var connection = ConfigService.Current.ConnectionString;
|
||||
if (connection.Length > 0)
|
||||
{
|
||||
store = new PatientVisitStore(connection);
|
||||
contexts = new PatientContextStore(connection);
|
||||
}
|
||||
Loaded += (_, _) => TermBox.Focus();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>고른 내원의 환자 문맥 — 취소했으면 null</summary>
|
||||
public PatientContext? Picked { get; private set; }
|
||||
|
||||
/// <summary>고른 것을 사람에게 보여 줄 한 줄. 미리보기 창 머리에 그대로 쓴다</summary>
|
||||
public string PickedLabel { get; private set; } = string.Empty;
|
||||
|
||||
/// <summary>
|
||||
/// 권한을 확인하고 창을 띄운다 — 못 쓰는 사용자에게는 사유를 말한다.
|
||||
/// 문을 여기 한 군데만 두면 호출부가 늘어도 빠뜨릴 곳이 없다.
|
||||
/// </summary>
|
||||
public static PatientContext? Pick(Window? owner, out string label)
|
||||
{
|
||||
label = string.Empty;
|
||||
if (!FormDesignDataBusiness.CanPreviewPatient())
|
||||
{
|
||||
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
||||
"실제 환자로 미리보기를 할 수 없습니다.",
|
||||
FormDesignDataBusiness.PreviewPatientDenyReason(), owner);
|
||||
return null;
|
||||
}
|
||||
var dialog = new PatientPickerDialogView { Owner = owner };
|
||||
if (dialog.ShowDialog() != true || dialog.Picked is not { } picked)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
// 감사 기록은 <b>고른 직후</b> 남긴다 — 종이에 값이 찍히는 것과 무관하게 조회는 이미 일어났다.
|
||||
// 환자 이름은 남기지 않는다(내원번호로 추적 가능하고, 로그가 개인정보를 들고 있으면 로그가 위험물이 된다).
|
||||
AppLog.Audit(PatientPreviewPolicy.AuditLine(
|
||||
UserSession.Current.UidCod, picked.ComNum.ToString("F0")));
|
||||
label = dialog.PickedLabel;
|
||||
return picked;
|
||||
}
|
||||
|
||||
private void OnTermKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key == Key.Enter)
|
||||
{
|
||||
e.Handled = true;
|
||||
OnSearch(sender, e);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>검색 유형의 사람이 읽는 이름 — 0건 안내가 "무엇으로 찾았는지" 말할 때 쓴다</summary>
|
||||
private static string KindLabel(PatientSearchKind kind) => kind switch
|
||||
{
|
||||
PatientSearchKind.Name => "성명 접두일치",
|
||||
PatientSearchKind.ResidentNumber => "주민번호 접두일치",
|
||||
PatientSearchKind.MobilePhone => "휴대전화 부분일치",
|
||||
_ => "차트번호 완전일치",
|
||||
};
|
||||
|
||||
private void OnSearch(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (store is null)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Warning, "환자 검색",
|
||||
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
|
||||
return;
|
||||
}
|
||||
var kind = KindCombo.SelectedIndex switch
|
||||
{
|
||||
1 => PatientSearchKind.Name,
|
||||
2 => PatientSearchKind.ResidentNumber,
|
||||
3 => PatientSearchKind.MobilePhone,
|
||||
_ => PatientSearchKind.ChartNumber,
|
||||
};
|
||||
|
||||
VisitList.ItemsSource = null;
|
||||
VisitCountText.Text = "내원";
|
||||
try
|
||||
{
|
||||
// 동기로 둔다. 행 상한이 200이라 오래 걸리지 않고, 비동기로 만들면
|
||||
// "검색 중에 또 검색"을 다뤄야 해서 이 창 크기에 맞지 않는 복잡도가 생긴다.
|
||||
Mouse.OverrideCursor = Cursors.Wait;
|
||||
SearchButton.IsEnabled = false;
|
||||
var found = store.Search(kind, TermBox.Text);
|
||||
var padded = string.Empty;
|
||||
|
||||
// 차트번호는 <b>완전일치</b>다. 병원마다 자릿수가 정해져 있어 실제 값은 0으로 채워져 있는데
|
||||
// (P_PatInf 가 고정 폭이다) 사람은 "12345" 처럼 앞의 0 을 빼고 친다 — 그러면 0건이다.
|
||||
// 저장소는 주석으로 "병원별 zero-pad 후 전달"을 요구하는데 이 호출부가 그걸 안 지켰다.
|
||||
//
|
||||
// <b>자릿수를 설정으로 정하지 않는다.</b> 병원마다 다르고 틀리면 멀쩡한 검색을 망친다.
|
||||
// 대신 0건일 때만 한 번 더 찾는다 — 8·10자리는 레거시가 쓰던 두 폭이다.
|
||||
// 찾았으면 무엇으로 찾았는지 칸에 되써서 사용자가 그 번호를 기억하게 한다.
|
||||
if (found.Count == 0 && kind == PatientSearchKind.ChartNumber
|
||||
&& TermBox.Text.Trim() is { Length: > 0 } typed && typed.All(char.IsDigit))
|
||||
{
|
||||
foreach (var width in new[] { 8, 10 })
|
||||
{
|
||||
if (typed.Length >= width)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var retry = store.Search(kind, typed.PadLeft(width, '0'));
|
||||
if (retry.Count > 0)
|
||||
{
|
||||
found = retry;
|
||||
padded = typed.PadLeft(width, '0');
|
||||
TermBox.Text = padded;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PatientList.ItemsSource = found;
|
||||
PatientCountText.Text = found.Count switch
|
||||
{
|
||||
// 0건일 때 <b>무엇으로 찾았는지</b> 말한다 — 유형이 틀려서 0건인 경우가 가장 흔한데
|
||||
// 전에는 그 힌트가 화면에 하나도 없어 접속이나 권한을 의심하게 됐다
|
||||
0 => $"환자 — 없습니다({KindLabel(kind)}로 찾았습니다)",
|
||||
_ when padded.Length > 0 => $"환자 {found.Count}명 — 자릿수를 채워 '{padded}' 로 찾았습니다",
|
||||
PatientVisitStore.DefaultLimit =>
|
||||
$"환자 {found.Count}명 — 상한에 걸렸습니다. 검색어를 좁히세요",
|
||||
_ => $"환자 {found.Count}명",
|
||||
};
|
||||
if (found.Count > 0)
|
||||
{
|
||||
PatientList.SelectedIndex = 0;
|
||||
PatientList.Focus();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Error, "환자 검색", "검색 중 오류가 발생했습니다.", ex.Message, this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
SearchButton.IsEnabled = true;
|
||||
Mouse.OverrideCursor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnPatientChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
{
|
||||
UpdateChosen();
|
||||
if (store is null || PatientList.SelectedItem is not PatientSummary patient)
|
||||
{
|
||||
return;
|
||||
}
|
||||
try
|
||||
{
|
||||
Mouse.OverrideCursor = Cursors.Wait;
|
||||
var visits = store.ListVisits(patient.ChtNum);
|
||||
VisitList.ItemsSource = visits;
|
||||
VisitCountText.Text = visits.Count == 0 ? "내원 — 없습니다" : $"내원 {visits.Count}건";
|
||||
if (visits.Count > 0)
|
||||
{
|
||||
VisitList.SelectedIndex = 0;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Error, "내원 조회", "내원을 읽지 못했습니다.", ex.Message, this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mouse.OverrideCursor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnVisitChanged(object sender, System.Windows.Controls.SelectionChangedEventArgs e)
|
||||
=> UpdateChosen();
|
||||
|
||||
/// <summary>고른 것을 글로 확인시킨다 — 목록 두 개짜리 화면에서 무엇이 선택됐는지는 쉽게 놓친다</summary>
|
||||
private void UpdateChosen()
|
||||
{
|
||||
var patient = PatientList.SelectedItem as PatientSummary;
|
||||
var visit = VisitList.SelectedItem as VisitSummary;
|
||||
SelectButton.IsEnabled = patient is not null && visit is not null;
|
||||
ChosenText.Text = (patient, visit) switch
|
||||
{
|
||||
(null, _) => "환자를 찾아 내원을 고르세요.",
|
||||
({ } p, null) => $"{p.Name}({p.ChtNum}) — 내원을 고르세요.",
|
||||
({ } p, { } v) => $"{p.Name}({p.ChtNum}) · {v.Kind} {v.AcceptedAtText} · 내원 {v.ComNum:F0}",
|
||||
};
|
||||
}
|
||||
|
||||
private void OnSelect(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (PatientList.SelectedItem is not PatientSummary patient
|
||||
|| VisitList.SelectedItem is not VisitSummary visit)
|
||||
{
|
||||
return;
|
||||
}
|
||||
if (contexts is null)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
||||
"DB 에 접속되어 있지 않습니다.", string.Empty, this);
|
||||
return;
|
||||
}
|
||||
var edge = EdgeCombo.SelectedIndex == 1 ? VisitEdge.First : VisitEdge.Last;
|
||||
try
|
||||
{
|
||||
Mouse.OverrideCursor = Cursors.Wait;
|
||||
var context = contexts.Load(visit.ComNum, edge);
|
||||
if (context is null)
|
||||
{
|
||||
// 목록에는 있는데 다시 읽을 때 없다 — 그 사이에 지워졌거나 권한이 다르다.
|
||||
// 창을 닫지 않는다. 닫으면 미리보기가 조용히 빈 종이를 낸다.
|
||||
DialogService.Notify(DialogKind.Warning, "환자 미리보기",
|
||||
"이 내원의 정보를 읽지 못했습니다.",
|
||||
$"내원 {visit.ComNum:F0} 을 다시 읽을 수 없습니다. 목록을 새로 검색해 보세요.", this);
|
||||
return;
|
||||
}
|
||||
Picked = context;
|
||||
PickedLabel = $"{patient.Name}({patient.ChtNum}) · {visit.Kind}"
|
||||
+ $" {visit.AcceptedAtText} · 기준 {Readable(context.AdpDtm)}";
|
||||
DialogResult = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
DialogService.Notify(DialogKind.Error, "환자 미리보기",
|
||||
"환자 정보를 읽는 중 오류가 발생했습니다.", ex.Message, this);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Mouse.OverrideCursor = null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string Readable(string stamp)
|
||||
=> stamp.Length == 12
|
||||
? $"{stamp[..4]}-{stamp[4..6]}-{stamp[6..8]} {stamp[8..10]}:{stamp[10..12]}"
|
||||
: stamp;
|
||||
#endregion
|
||||
}
|
||||
@@ -1,22 +1,93 @@
|
||||
<Window x:Class="SheetMe.Designer.Views.PreviewWindow"
|
||||
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||
Title="미리보기" Width="900" Height="1000"
|
||||
WindowStartupLocation="CenterOwner" Background="{DynamicResource B.CanvasBg}"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<Window.Resources>
|
||||
<ctl:IconNameToVisualConverter x:Key="IconName"/>
|
||||
</Window.Resources>
|
||||
<DockPanel>
|
||||
<ToolBarTray DockPanel.Dock="Top">
|
||||
<ToolBar>
|
||||
<Button Content="인쇄..." Padding="10,3" Click="OnPrint"/>
|
||||
<!--
|
||||
아이콘 버튼으로 둔다. 전에는 여덟 개가 전부 글자라 툴바가 글자 벽이었고,
|
||||
무엇이 명령이고 무엇이 상태인지 구분되지 않았다.
|
||||
글자로 남기는 것은 <b>값과 상태</b>뿐이다 — 고른 환자 이름과 배율.
|
||||
이름은 툴팁이 받는다(셸 제목줄의 아이콘 줄과 같은 규범).
|
||||
-->
|
||||
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
|
||||
Click="OnPrint" ToolTip="인쇄..."
|
||||
Content="{Binding Source=printer, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<Button Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
|
||||
Click="OnRefresh"
|
||||
ToolTip="다시 그리기 — 구조 변경은 자동으로 따라오고, 창을 누르면 다시 그립니다"
|
||||
Content="{Binding Source=rotate-cw, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<Separator/>
|
||||
<Button Content="-" Padding="8,3" Click="OnZoomOut"/>
|
||||
<!--
|
||||
레거시 미리보기와 레거시 인쇄가 서로 다르다 — 미리보기는 PrintOutPut=False 를
|
||||
그대로 보여 주고 인쇄는 뺀다. 둘 다 확인할 수 있어야 해서 보기를 가른다.
|
||||
기본은 인쇄 기준 — 저장 전에 확인하려는 것이 대개 종이 결과다.
|
||||
토글은 눌린 상태가 곧 '인쇄 기준'이라는 뜻이다(툴팁이 양쪽을 설명한다).
|
||||
-->
|
||||
<ToggleButton x:Name="PrintViewToggle" IsChecked="True"
|
||||
Style="{StaticResource MiniToggle}" Width="34" Height="28" Padding="0"
|
||||
Checked="OnViewToggled" Unchecked="OnViewToggled"
|
||||
ToolTip="인쇄될 것만 보기 — 끄면 화면에 보이는 대로(인쇄 출력을 끈 컨트롤도 보입니다)"
|
||||
Content="{Binding Source=eye, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<Separator/>
|
||||
<!--
|
||||
실제 환자로 미리보기 — 태그가 값을 뽑는지 확인하는 유일한 방법이다.
|
||||
고르지 않은 상태가 기본이다. 여는 순간 환자를 붙이면 미리보기를 열 때마다
|
||||
환자 정보를 조회하게 되고, 그건 이 도구가 할 일이 아니다.
|
||||
|
||||
선택·해제를 <b>한 버튼</b>으로 합쳤다 — 셸 제목줄의 환자 아이콘과 같은 방식이다.
|
||||
안 골랐으면 바로 선택 창, 이미 골랐으면 바꾸기·해제 메뉴를 띄운다.
|
||||
늘 보이는 자리에서 '누르면 해제'로 두면 실수로 지운다.
|
||||
-->
|
||||
<Button x:Name="PatientButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
|
||||
Click="OnPickPatient"
|
||||
ToolTip="환자 선택 — 실제 환자·내원을 골라 태그에 값이 들어간 상태로 확인합니다"/>
|
||||
<!--
|
||||
데이터소스 결과 — 종이에 값이 안 찍혔을 때 왜인지는 여기서만 알 수 있다.
|
||||
"[MDataTable1.ALGYON — 조회 결과가 0행입니다]" 한 줄로는 쿼리가 틀렸는지,
|
||||
치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 가릴 수 없다.
|
||||
-->
|
||||
<Button x:Name="DataSourceButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
|
||||
Click="OnShowDataSources" IsEnabled="False"
|
||||
ToolTip="데이터소스 결과 — MDataTable 조회 결과와 치환을 마친 SQL 을 봅니다"
|
||||
Content="{Binding Source=table, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<!--
|
||||
미리보기에서 눌러 본 체크·라디오를 원래대로. 문서는 처음부터 안 건드리므로
|
||||
되돌릴 것이 없고, 여기서 지우는 것은 "눌러 본 흔적"뿐이다.
|
||||
-->
|
||||
<Button x:Name="ResetChecksButton" Style="{StaticResource Subtle}" Width="34" Height="28" Padding="0"
|
||||
Click="OnResetChecks" IsEnabled="False"
|
||||
ToolTip="누른 것 초기화 — 미리보기에서 눌러 본 체크·라디오를 서식의 기본 상태로 돌립니다"
|
||||
Content="{Binding Source=undo-2, Converter={StaticResource IconName}, ConverterParameter=16}"/>
|
||||
<!-- 고른 환자는 <b>글자로</b> 남긴다 — 이름은 아이콘으로 대신할 수 없는 값이다 -->
|
||||
<TextBlock x:Name="PatientText" VerticalAlignment="Center" Margin="8,0"
|
||||
MaxWidth="360" TextTrimming="CharacterEllipsis"
|
||||
Foreground="{DynamicResource B.Muted}" Text="환자 없음"/>
|
||||
<Separator/>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
|
||||
Click="OnZoomOut" ToolTip="축소"
|
||||
Content="{Binding Source=minus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
|
||||
<TextBlock x:Name="ZoomText" Text="100%" VerticalAlignment="Center" Margin="6,0" MinWidth="42" TextAlignment="Center"/>
|
||||
<Button Content="+" Padding="8,3" Click="OnZoomIn"/>
|
||||
<Button Style="{StaticResource Subtle}" Width="30" Height="28" Padding="0"
|
||||
Click="OnZoomIn" ToolTip="확대"
|
||||
Content="{Binding Source=plus, Converter={StaticResource IconName}, ConverterParameter=14}"/>
|
||||
</ToolBar>
|
||||
</ToolBarTray>
|
||||
|
||||
<ScrollViewer HorizontalScrollBarVisibility="Auto" VerticalScrollBarVisibility="Auto">
|
||||
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center">
|
||||
<!--
|
||||
종이를 눌러 본다 — 레거시 미리보기(TK_PREVIEW)는 정적 그림이 아니라 런타임 모드라
|
||||
체크·라디오를 실제로 눌러 확인한다. 클릭은 미리보기 안에서만 살고 문서를 바꾸지 않는다.
|
||||
-->
|
||||
<StackPanel x:Name="PagesHost" Margin="24" HorizontalAlignment="Center"
|
||||
MouseLeftButtonUp="OnPaperClick" MouseMove="OnPaperMouseMove">
|
||||
<StackPanel.LayoutTransform>
|
||||
<ScaleTransform x:Name="ZoomTransform" ScaleX="1" ScaleY="1"/>
|
||||
</StackPanel.LayoutTransform>
|
||||
|
||||
@@ -1,31 +1,171 @@
|
||||
using System.Windows;
|
||||
using System.Windows.Controls;
|
||||
using System.Windows.Documents;
|
||||
using SheetMe.Designer.Services;
|
||||
using SheetMe.Designer.ViewModels;
|
||||
using SheetMe.Designer.ViewModels.Controls;
|
||||
|
||||
namespace SheetMe.Designer.Views;
|
||||
|
||||
/// <summary>미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.</summary>
|
||||
/// <summary>
|
||||
/// 미리보기 창 — 편집 크롬 없는 페이지 렌더(인쇄와 동일 비주얼) + 줌/인쇄.
|
||||
///
|
||||
/// <b>창은 하나만 뜬다.</b> 전에는 누를 때마다 새 창이 생겼다. 모덜리스라 여러 장이 겹쳐 쌓이고,
|
||||
/// 각각이 <b>서로 다른 시점의 서식</b>을 들고 있었다. 어느 것이 지금 것인지 화면으로 구분할 수 없다.
|
||||
/// 환자 데이터가 들어가면 그건 개인정보 사고가 된다 — 환자를 바꿔도 옛 창이 옛 환자를 계속 띄운다.
|
||||
///
|
||||
/// <b>내용은 따라간다.</b> 전에는 생성자에서 한 번만 그려서, 컨트롤을 추가·삭제하거나
|
||||
/// 숨김·인쇄출력을 토글해도 창은 옛 그림을 계속 보여 줬다 —
|
||||
/// 저장 전에 결과를 확인하려고 여는 창이 <b>구조적으로 거짓말</b>을 하고 있었다.
|
||||
/// 페이지·컨트롤 추가/삭제는 즉시, 그 밖의 변경은 창을 누르는 순간(Activated) 다시 그린다.
|
||||
/// 즉시 갱신이 안 되는 변경이 남아 있으므로 <b>새로 고침 버튼</b>도 함께 둔다.
|
||||
/// </summary>
|
||||
public partial class PreviewWindow : Window
|
||||
{
|
||||
#region Member Fields
|
||||
private readonly DesignerViewModel designer;
|
||||
/// <summary>지금 떠 있는 창 — 두 번째를 만들지 않는다</summary>
|
||||
private static PreviewWindow? open;
|
||||
|
||||
private DesignerViewModel designer;
|
||||
private double zoom = 1.0;
|
||||
|
||||
/// <summary>
|
||||
/// 시각·로그인 사용자 태그 해석기 — 창당 하나. DB 재료를 한 번만 읽고 창이 닫힐 때까지 들고 있다.
|
||||
/// 그리기마다 새로 만들면 다시 그릴 때마다 DB 를 친다.
|
||||
/// </summary>
|
||||
private readonly SessionTagResolver session = new();
|
||||
|
||||
/// <summary>
|
||||
/// 지금 쓰는 해석기. 환자를 고르면 <see cref="PatientTagResolver"/> 가 앞에 붙고,
|
||||
/// 해제하면 다시 <see cref="session"/> 뿐이다 — 환자를 해제했는데 옛 값이 남으면
|
||||
/// 화면이 실제와 다른 것을 보여 준다.
|
||||
/// </summary>
|
||||
private SheetMe.Core.Catalog.ITagValueResolver tags;
|
||||
|
||||
/// <summary>
|
||||
/// MDataTable 실행기 — <c>DataTableField</c> 배선의 값을 낸다.
|
||||
/// 환자를 바꾸면 새로 만든다(캐시에 옛 환자의 표가 남으면 안 된다).
|
||||
/// </summary>
|
||||
private MDataTableRunner? fields;
|
||||
|
||||
/// <summary>지금 붙어 있는 환자 — 서식을 갈아탈 때 러너를 다시 만들어야 해서 들고 있다</summary>
|
||||
private SheetMe.Data.Stores.PatientContext? patient;
|
||||
|
||||
/// <summary>
|
||||
/// 이 창에서 눌러 본 체크·라디오 — <b>문서에는 쓰지 않는다</b>.
|
||||
/// 창이 닫히면 사라지고, 캔버스의 서식은 한 글자도 바뀌지 않는다.
|
||||
/// </summary>
|
||||
private readonly SheetMe.Core.Catalog.PreviewCheckState checks = new();
|
||||
#endregion
|
||||
|
||||
#region Constructors
|
||||
public PreviewWindow(DesignerViewModel designer)
|
||||
/// <summary>
|
||||
/// 진단(--dialog-shots)만 직접 부른다 — 앱 코드는 <see cref="ShowFor"/> 를 쓸 것.
|
||||
/// 여기로 만들면 단일 인스턴스 등록을 건너뛰므로 두 장이 생긴다.
|
||||
/// </summary>
|
||||
internal PreviewWindow(DesignerViewModel designer)
|
||||
{
|
||||
this.designer = designer;
|
||||
tags = session;
|
||||
InitializeComponent();
|
||||
BuildPages();
|
||||
Activated += (_, _) => BuildPages();
|
||||
// 환자는 앱 전체가 하나를 공유한다 — 제목줄 아이콘에서 바꿔도 이 창이 따라와야 한다
|
||||
PatientSession.Changed += OnPatientSessionChanged;
|
||||
Closed += (_, _) =>
|
||||
{
|
||||
Unsubscribe();
|
||||
PatientSession.Changed -= OnPatientSessionChanged;
|
||||
if (ReferenceEquals(open, this))
|
||||
{
|
||||
open = null;
|
||||
}
|
||||
};
|
||||
Subscribe();
|
||||
// 이미 다른 화면에서 고른 환자가 있으면 그것을 그대로 물려받는다 —
|
||||
// 창을 열 때마다 초기화하면 "앱 전체 적용"이 거짓이 된다.
|
||||
// 환자가 없어도 러너는 만든다(데이터소스 개수를 알아야 버튼 상태가 맞다).
|
||||
UsePatient(PatientSession.Current, PatientSession.Label);
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region Methods
|
||||
/// <summary>
|
||||
/// 미리보기를 보여 준다 — 이미 떠 있으면 그 창을 <b>지금 문서로 바꿔</b> 앞으로 가져온다.
|
||||
/// 새 창을 하나 더 만들면 어느 것이 지금 것인지 알 수 없다.
|
||||
/// </summary>
|
||||
public static void ShowFor(DesignerViewModel designer, Window? owner)
|
||||
{
|
||||
if (open is null)
|
||||
{
|
||||
open = new PreviewWindow(designer) { Owner = owner };
|
||||
open.Show();
|
||||
return;
|
||||
}
|
||||
open.Retarget(designer);
|
||||
if (open.WindowState == WindowState.Minimized)
|
||||
{
|
||||
open.WindowState = WindowState.Normal;
|
||||
}
|
||||
open.Activate();
|
||||
}
|
||||
|
||||
/// <summary>다른 문서로 갈아탄다 — 탭을 바꾸고 미리보기를 다시 누른 경우</summary>
|
||||
private void Retarget(DesignerViewModel next)
|
||||
{
|
||||
if (ReferenceEquals(designer, next))
|
||||
{
|
||||
BuildPages();
|
||||
return;
|
||||
}
|
||||
Unsubscribe();
|
||||
designer = next;
|
||||
Subscribe();
|
||||
// 서식이 바뀌면 데이터소스도 바뀐다 — 러너를 다시 만들지 않으면 <b>옛 서식의 쿼리</b>로
|
||||
// 새 서식의 배선을 채우려 든다. 환자는 그대로 유지한다(같은 환자로 다른 서식을 보는 일이 흔하다).
|
||||
UsePatient(patient, PatientText.Text);
|
||||
}
|
||||
|
||||
/// <summary>페이지·컨트롤 추가/삭제는 즉시 따라간다</summary>
|
||||
private void Subscribe()
|
||||
{
|
||||
designer.Pages.CollectionChanged += OnStructureChanged;
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
page.Controls.CollectionChanged += OnStructureChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Unsubscribe()
|
||||
{
|
||||
designer.Pages.CollectionChanged -= OnStructureChanged;
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
page.Controls.CollectionChanged -= OnStructureChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnStructureChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
// 페이지가 늘거나 줄면 그 페이지의 컨트롤 구독도 다시 걸어야 한다
|
||||
if (ReferenceEquals(sender, designer.Pages))
|
||||
{
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
page.Controls.CollectionChanged -= OnStructureChanged;
|
||||
page.Controls.CollectionChanged += OnStructureChanged;
|
||||
}
|
||||
}
|
||||
BuildPages();
|
||||
}
|
||||
|
||||
private void BuildPages()
|
||||
{
|
||||
// XAML 파싱 중에도 불린다 — 토글의 IsChecked="True" 가 파스 시점에 Checked 를 발생시키는데
|
||||
// 그때 PagesHost 는 아직 만들어지지 않았다(토글이 트리에서 앞에 있다).
|
||||
// 이 가드가 없으면 InitializeComponent 안에서 NullReferenceException 이 난다.
|
||||
if (PagesHost is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
PagesHost.Children.Clear();
|
||||
foreach (var page in designer.Pages)
|
||||
{
|
||||
@@ -35,21 +175,249 @@ public partial class PreviewWindow : Window
|
||||
BorderBrush = System.Windows.Media.Brushes.LightGray,
|
||||
BorderThickness = new Thickness(1),
|
||||
Margin = new Thickness(0, 0, 0, 20),
|
||||
Child = PrintService.BuildPageVisual(page),
|
||||
Child = PrintService.BuildPageVisual(page, PrintViewToggle.IsChecked == true, tags, fields, checks),
|
||||
Width = page.WidthDip,
|
||||
Height = page.HeightDip,
|
||||
// 어느 종이를 눌렀는지 되찾는 끈 — 클릭 좌표를 페이지 모델로 옮기는 데 쓴다
|
||||
Tag = page,
|
||||
};
|
||||
PagesHost.Children.Add(frame);
|
||||
}
|
||||
ResetChecksButton.IsEnabled = checks.HasAny;
|
||||
Title = designer.Document.Title.Length > 0
|
||||
? $"미리보기 — {designer.Document.Title}"
|
||||
: "미리보기";
|
||||
}
|
||||
|
||||
private void OnRefresh(object sender, RoutedEventArgs e) => BuildPages();
|
||||
|
||||
/// <summary>
|
||||
/// 종이 위 체크·라디오를 눌렀다 — <b>미리보기 안에서만</b> 상태가 바뀐다.
|
||||
///
|
||||
/// <b>왜 이 창에서 클릭이 되어야 하나.</b> 레거시 미리보기는 정적 그림이 아니라 런타임 모드
|
||||
/// (<c>TK_PREVIEW</c>)라서 실제로 눌러 보며 확인한다. 라디오가 배타로 묶였는지는
|
||||
/// <b>눌러 보는 것 말고 확인할 방법이 없다</b> — 패널로 묶었다고 믿고 저장했다가
|
||||
/// 임상 화면에서 두 개가 동시에 켜지는 것이 이 서식에서 가장 흔한 사고다.
|
||||
///
|
||||
/// <b>왜 문서를 안 건드리나.</b> 확인하려고 누른 것이 서식 수정이 되면 미리보기를 열었다 닫은
|
||||
/// 것만으로 저장할 내용이 생긴다. 그래서 상태는 <see cref="checks"/> 에만 쌓고 렌더에서 덮는다.
|
||||
///
|
||||
/// <b>왜 WPF 히트테스트가 아닌가.</b> 종이 비주얼은 템플릿으로 만든 그림이라
|
||||
/// 컨트롤 하나가 여러 요소(테두리·글자·표시)로 쪼개져 있고 일부는 히트테스트에서 빠져 있다.
|
||||
/// 모델 트리를 좌표로 훑으면 캔버스의 <c>HitTestControl</c> 과 같은 규칙을 그대로 쓸 수 있다.
|
||||
/// </summary>
|
||||
private void OnPaperClick(object sender, System.Windows.Input.MouseButtonEventArgs e)
|
||||
{
|
||||
if (Locate(e) is not { } target)
|
||||
{
|
||||
return;
|
||||
}
|
||||
checks.Click(target.Hit.Model, target.Siblings.Select(vm => vm.Model).ToList());
|
||||
BuildPages();
|
||||
}
|
||||
|
||||
/// <summary>누를 수 있는 것 위에서는 손가락 커서 — 안 그러면 클릭이 되는지 알 수 없다</summary>
|
||||
private void OnPaperMouseMove(object sender, System.Windows.Input.MouseEventArgs e)
|
||||
=> Cursor = Locate(e) is null
|
||||
? System.Windows.Input.Cursors.Arrow
|
||||
: System.Windows.Input.Cursors.Hand;
|
||||
|
||||
/// <summary>
|
||||
/// 마우스 위치의 누를 수 있는 컨트롤과 <b>그 직계 형제들</b> — 없으면 null.
|
||||
///
|
||||
/// 형제 목록까지 함께 내는 이유: 라디오 배타는 직계 부모가 가른다
|
||||
/// (레거시 RadioButton 은 <c>GroupName</c> 이 없다) — 누른 것만 알면 무엇을 꺼야 할지 모른다.
|
||||
/// </summary>
|
||||
private (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? Locate(
|
||||
System.Windows.Input.MouseEventArgs e)
|
||||
{
|
||||
var printFilter = PrintViewToggle.IsChecked == true;
|
||||
foreach (var child in PagesHost.Children)
|
||||
{
|
||||
if (child is not Border { Tag: PageViewModel page, Child: { } paper })
|
||||
{
|
||||
continue;
|
||||
}
|
||||
// 종이(Canvas) 기준 좌표로 받는다 — 테두리 1px 과 줌 배율이 여기서 함께 풀린다
|
||||
var local = e.GetPosition(paper);
|
||||
if (local.X < 0 || local.Y < 0 || local.X > page.WidthDip || local.Y > page.HeightDip)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
return HitInPreview(page.Controls, local, printFilter);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 그려진 것 중 좌표 위 최상위 컨트롤 — 컨테이너는 자식 우선.
|
||||
///
|
||||
/// 캔버스의 히트테스트와 두 군데가 다르다.
|
||||
/// ① <c>Locked</c> 를 보지 않는다 — 잠금은 편집 보호일 뿐 런타임에서는 눌린다.
|
||||
/// ② 그려지지 않은 것(숨김·데이터소스·인쇄 제외)은 뺀다 — 안 보이는 것을 누를 수는 없다.
|
||||
///
|
||||
/// 맨 위가 누를 수 없는 것(라벨 등)이면 그대로 아무 일도 없다 — 아래로 흘리지 않는다.
|
||||
/// WinForms 도 맨 위 컨트롤에만 클릭을 준다.
|
||||
/// </summary>
|
||||
private static (ControlViewModel Hit, IReadOnlyList<ControlViewModel> Siblings)? HitInPreview(
|
||||
IList<ControlViewModel> controls, Point local, bool printFilter)
|
||||
{
|
||||
for (var i = controls.Count - 1; i >= 0; i--)
|
||||
{
|
||||
var vm = controls[i];
|
||||
if (vm.Model.Hidden || vm is DataTableViewModel
|
||||
|| (printFilter && !SheetMe.Core.Catalog.PrintFilter.IsPrinted(vm.Model)))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
var rect = new Rect(vm.X, vm.Y, Math.Max(1, vm.Width), Math.Max(1, vm.Height));
|
||||
if (!rect.Contains(local))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (vm is ContainerViewModel container
|
||||
&& HitInPreview(container.Children, new Point(local.X - vm.X, local.Y - vm.Y), printFilter)
|
||||
is { } childHit)
|
||||
{
|
||||
return childHit;
|
||||
}
|
||||
return SheetMe.Core.Catalog.PreviewCheckState.IsClickable(vm.Model)
|
||||
? (vm, controls.ToList())
|
||||
: null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>눌러 본 것을 전부 원래대로 — 문서는 애초에 안 바뀌었으니 되돌릴 것이 없다</summary>
|
||||
private void OnResetChecks(object sender, RoutedEventArgs e)
|
||||
{
|
||||
checks.Clear();
|
||||
BuildPages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 실제 환자를 골라 태그에 값을 넣는다.
|
||||
///
|
||||
/// 권한 문·감사 기록·문맥 읽기는 모두 <see cref="PatientPickerDialogView.Pick"/> 안에 있다 —
|
||||
/// 여기서 다시 확인하면 두 곳이 어긋날 수 있다.
|
||||
/// </summary>
|
||||
private void OnPickPatient(object sender, RoutedEventArgs e)
|
||||
{
|
||||
// 안 골랐으면 바로 선택 창. 이미 골랐으면 <b>메뉴</b>다 —
|
||||
// 늘 보이는 아이콘에서 '누르면 해제'로 두면 실수로 지운다.
|
||||
// 셸 제목줄의 환자 아이콘과 같은 규범이다(버튼 두 개를 하나로 합쳤다).
|
||||
if (!PatientSession.HasPatient)
|
||||
{
|
||||
Pick();
|
||||
return;
|
||||
}
|
||||
var menu = new System.Windows.Controls.ContextMenu
|
||||
{
|
||||
PlacementTarget = PatientButton,
|
||||
Placement = System.Windows.Controls.Primitives.PlacementMode.Bottom,
|
||||
};
|
||||
var header = new System.Windows.Controls.MenuItem { Header = PatientSession.Label, IsEnabled = false };
|
||||
var change = new System.Windows.Controls.MenuItem { Header = "환자 바꾸기..." };
|
||||
change.Click += (_, _) => Pick();
|
||||
var clear = new System.Windows.Controls.MenuItem { Header = "환자 해제" };
|
||||
clear.Click += (_, _) => PatientSession.Set(null, string.Empty);
|
||||
menu.Items.Add(header);
|
||||
menu.Items.Add(new System.Windows.Controls.Separator());
|
||||
menu.Items.Add(change);
|
||||
menu.Items.Add(clear);
|
||||
menu.IsOpen = true;
|
||||
|
||||
void Pick()
|
||||
{
|
||||
// 권한 문·감사 기록·문맥 읽기는 전부 Pick 안에 있다 — 호출부마다 다시 확인하면 어긋난다.
|
||||
// 세션에만 쓴다 — 그러면 Changed 가 이 창을 포함한 <b>모든</b> 화면을 갱신한다.
|
||||
if (PatientPickerDialogView.Pick(this, out var label) is { } picked)
|
||||
{
|
||||
PatientSession.Set(picked, label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>환자를 뗀다 — 종이에서 값이 사라지고 사유로 돌아가는 것까지 확인해야 한다</summary>
|
||||
private void OnPatientSessionChanged(object? sender, EventArgs e)
|
||||
=> UsePatient(PatientSession.Current, PatientSession.Label);
|
||||
|
||||
/// <summary>
|
||||
/// 환자를 붙이거나 뗀다 — 해석기 교체·표시·다시 그리기를 한 군데에 모은다.
|
||||
///
|
||||
/// <b>모달과 분리하는 이유</b>: 고르는 일(권한·감사·문맥 읽기)과 붙이는 일(해석기 교체 → 종이)은
|
||||
/// 다른 일이고, 붙이는 쪽만 진단이 확인할 수 있어야 한다. 창을 띄우는 코드에 묻어 두면
|
||||
/// "환자를 골랐는데 종이가 그대로다"를 자동으로 잡을 방법이 없다 — 그게 이 단계에서 가장 무서운 고장이다.
|
||||
/// </summary>
|
||||
internal void UsePatient(SheetMe.Data.Stores.PatientContext? picked, string label)
|
||||
{
|
||||
patient = picked;
|
||||
// 눌러 본 흔적은 여기서 지운다. 환자가 바뀌면 태그가 만드는 체크 상태도 바뀌는데,
|
||||
// 손으로 누른 값이 그 위에 남아 있으면 <b>새 환자의 실제 값을 가린다</b> —
|
||||
// 확인하려고 만든 창이 확인을 막는 셈이다. 서식을 갈아탈 때도 같은 이유로 지운다.
|
||||
checks.Clear();
|
||||
// 주민번호는 환자를 붙일 때 한 번만 만든다 — 태그마다 만들면 DB 왕복이 그만큼 늘고
|
||||
// 가장 민감한 값을 여러 번 꺼내게 된다.
|
||||
tags = picked is null
|
||||
? session
|
||||
: new PatientTagResolver(picked, session,
|
||||
PatientIdentity.ResidentNumberOf(picked), PatientIdentity.AddressOf(picked),
|
||||
PatientIdentity.DoctorOf(picked), PatientIdentity.StaffDoctorOf(picked),
|
||||
PatientIdentity.DepartmentOf(picked),
|
||||
PatientIdentity.HospitalRowOf(picked),
|
||||
PatientIdentity.DiagnosisSourceFor(picked),
|
||||
PatientIdentity.SurgerySourceFor(picked),
|
||||
PatientIdentity.ReviewDiagnosisSourceFor(picked),
|
||||
PatientIdentity.VitalSourceFor(picked),
|
||||
PatientIdentity.ScheduledSurgerySourceFor(picked),
|
||||
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
|
||||
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
|
||||
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
|
||||
PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
|
||||
PatientIdentity.ShareMap());
|
||||
// 러너를 <b>새로</b> 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
|
||||
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
|
||||
fields = new MDataTableRunner(designer.Document,
|
||||
picked is null ? null : new PatientQueryVariableSource(picked, UserSession.Current.UidCod));
|
||||
PatientText.Text = picked is null ? "환자 없음" : label;
|
||||
// 고른 상태는 <b>아이콘 색</b>으로 알린다 — 셸 제목줄과 같다.
|
||||
// 안 고른 상태의 색은 B.Muted 여야 한다(null 을 넘기면 하드코딩 검정으로 칠해져
|
||||
// 어두운 툴바에서 혼자 묻힌다 — 제목줄에서 실제로 겪은 결함이다).
|
||||
PatientButton.Content = Controls.LucideIcons.Icon("user", 16,
|
||||
picked is null ? "B.Muted" : "B.Accent");
|
||||
DataSourceButton.IsEnabled = fields.Names.Count > 0;
|
||||
BuildPages();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 데이터소스 조회 결과를 펼쳐 본다 — 종이에 값이 안 찍혔을 때 <b>왜</b>인지는 여기서만 알 수 있다.
|
||||
///
|
||||
/// 종이에는 "[MDataTable1.ALGYON — 조회 결과가 0행입니다]"처럼 한 줄만 나온다.
|
||||
/// 그것으로는 쿼리가 틀렸는지, 치환이 빈 값이 됐는지, 이 환자에게 정말 자료가 없는지 못 가른다.
|
||||
/// 치환을 마친 SQL 원문을 보여 주는 것이 이 창의 핵심이다.
|
||||
/// </summary>
|
||||
private void OnShowDataSources(object sender, RoutedEventArgs e)
|
||||
{
|
||||
if (fields is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
DataSourceResultDialogView.ShowFor(fields, this);
|
||||
}
|
||||
|
||||
/// <summary>보기 전환 — 인쇄 기준 / 화면 그대로</summary>
|
||||
private void OnViewToggled(object sender, RoutedEventArgs e) => BuildPages();
|
||||
|
||||
private void OnPrint(object sender, RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
// 이 창이 종이에 그리는 것과 <b>같은 해석기</b>로 인쇄한다.
|
||||
// 안 넘기면 화면에는 환자 값이 찍히는데 인쇄물은 태그 이름 그대로가 된다 —
|
||||
// 실제 인쇄 PDF 와 화면을 나란히 놓고서야 발견한 결함이다.
|
||||
PrintService.Print(designer, designer.Document.Title.Length > 0
|
||||
? designer.Document.Title
|
||||
: designer.Document.FormId);
|
||||
: designer.Document.FormId, tags, fields, checks);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
xmlns:ctl="clr-namespace:SheetMe.Designer.Controls"
|
||||
Title="쿼리 편집" Width="1180" Height="760" MinWidth="880" MinHeight="520"
|
||||
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
|
||||
Style="{StaticResource ThemedWindow}">
|
||||
Style="{StaticResource ThemedDialogWindow}">
|
||||
<Window.Resources>
|
||||
<!-- 처음 펼쳐 둘 그룹 판단 — 가장 많이 쓰는 '환자'와 이 서식의 컨트롤만 펴 둔다 -->
|
||||
<ctl:QueryGroupExpandedConverter x:Key="GroupExpanded"/>
|
||||
@@ -241,6 +241,14 @@
|
||||
<TextBlock Text="검증 실행" VerticalAlignment="Center"/>
|
||||
</StackPanel>
|
||||
</Button>
|
||||
<!--
|
||||
환자 선택 — 검증 실행을 <b>실제 값</b>으로 돌리려면 여기서 고를 수 있어야 한다.
|
||||
전에는 미리보기 창 안에만 있어서, 쿼리를 고치는 사람은 값 확인을 할 수 없었다
|
||||
(변수가 NULL 로 들어가 늘 0행이었고, 그건 "안 된다"로 읽힌다).
|
||||
고른 환자는 세션 하나를 공유하므로 미리보기와 같은 환자다.
|
||||
-->
|
||||
<Button x:Name="PatientButton" Padding="14,8" Margin="10,0,0,0" Click="OnPickPatient"
|
||||
ToolTip="환자를 고르면 치환 변수에 실제 값이 들어가 값까지 확인됩니다"/>
|
||||
<Button Content="확인" Padding="30,8" Margin="10,0,0,0"
|
||||
Style="{StaticResource Primary}" Click="OnConfirm" IsDefault="True"/>
|
||||
<Button Content="취소" Padding="26,8" Margin="10,0,0,0" IsCancel="True"/>
|
||||
@@ -259,7 +267,11 @@
|
||||
|
||||
<!-- ══ SQL 편집기 카드 ══ -->
|
||||
<Border Grid.Column="0" Style="{StaticResource Card}">
|
||||
<DockPanel Margin="14,12,14,12">
|
||||
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
|
||||
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
|
||||
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
|
||||
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<TextBlock DockPanel.Dock="Top" Text="SQL 편집기" Style="{StaticResource CardTitle}"
|
||||
Margin="0,0,0,10"/>
|
||||
|
||||
@@ -305,13 +317,39 @@
|
||||
</DockPanel>
|
||||
</Border>
|
||||
|
||||
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10">
|
||||
<DockPanel x:Name="TrialPane" Visibility="Collapsed" Margin="12,10,12,10"
|
||||
Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||
<Button DockPanel.Dock="Right" Content="닫기"
|
||||
Style="{StaticResource LinkBtn}" Click="OnCloseTrial"/>
|
||||
<!--
|
||||
무엇을 돌렸는지 보여 준다. 0행이 나왔을 때
|
||||
'치환이 NULL 이라 조건이 사라진 건지, 이 환자에게 자료가 없는 건지'를
|
||||
창을 나가지 않고 가르는 유일한 수단이다 — 치환을 마친 문장이 답을 갖고 있다.
|
||||
-->
|
||||
<Button x:Name="ShowTrialSqlButton" DockPanel.Dock="Right"
|
||||
Content="돌린 SQL 보기" Margin="0,0,10,0"
|
||||
Style="{StaticResource LinkBtn}" Click="OnToggleTrialSql"/>
|
||||
<TextBlock x:Name="TrialSummary" FontSize="12.5" TextWrapping="Wrap"
|
||||
VerticalAlignment="Center"/>
|
||||
</DockPanel>
|
||||
<!-- 돌린 SQL 원문 — 읽기 전용. 08c 데이터소스 결과 창과 같은 구성이다 -->
|
||||
<Border x:Name="TrialSqlPane" DockPanel.Dock="Top" Visibility="Collapsed"
|
||||
Margin="0,0,0,8" BorderThickness="1"
|
||||
BorderBrush="{DynamicResource B.Line2}"
|
||||
Background="{DynamicResource B.Input}">
|
||||
<DockPanel>
|
||||
<Button DockPanel.Dock="Right" Content="SQL 복사" VerticalAlignment="Top"
|
||||
Margin="6,4,6,4" Style="{StaticResource LinkBtn}"
|
||||
Click="OnCopyTrialSql"/>
|
||||
<TextBox x:Name="TrialSqlText" IsReadOnly="True" BorderThickness="0"
|
||||
Background="Transparent" FontFamily="Consolas" FontSize="12"
|
||||
TextWrapping="Wrap" MaxHeight="120" Padding="8,6"
|
||||
VerticalScrollBarVisibility="Auto"
|
||||
AutomationProperties.Name="돌린 SQL"/>
|
||||
</DockPanel>
|
||||
</Border>
|
||||
<!-- 표를 감싸는 테두리 — 표 자체는 테두리를 그리지 않는다(위 스타일) -->
|
||||
<Border BorderThickness="1" BorderBrush="{DynamicResource B.Line2}">
|
||||
<DataGrid x:Name="TrialGrid" Style="{StaticResource ResultGrid}" MaxHeight="200"
|
||||
@@ -326,8 +364,13 @@
|
||||
본문 사이 구분선까지 함께 휘어 보인다. -->
|
||||
<!-- 본문 면은 B.Surface 다. B.Input 을 쓰면 다크에서 홈통(B.Chip)과 같은 #262626 이라
|
||||
홈통이 통째로 사라진다(양 토큰이 같은 값이다). -->
|
||||
<!-- ClearTypeHint 를 여기에 건다. 카드 DockPanel 에만 걸어 봤더니 SQL 글자는
|
||||
계속 뭉개졌다 — 본문은 이 아래 SqlHighlightLayer 가 그리고, 그 사이에
|
||||
또 다른 중간 서피스가 있다(진단 cleartype 표본 I·J — ScrollViewer 도 경계다).
|
||||
이 Border 는 불투명 B.Surface 를 갖고 Effect 경계 안쪽이라 조건이 맞는다. -->
|
||||
<Border BorderThickness="1" CornerRadius="0"
|
||||
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}">
|
||||
BorderBrush="{DynamicResource B.Line2}" Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<Grid>
|
||||
<Grid.ColumnDefinitions>
|
||||
<ColumnDefinition Width="Auto"/>
|
||||
@@ -393,14 +436,21 @@
|
||||
|
||||
<!-- ══ 치환 변수 카드 ══ -->
|
||||
<Border x:Name="VariablePane" Grid.Column="2" Style="{StaticResource Card}">
|
||||
<DockPanel Margin="14,12,14,12">
|
||||
<!-- 불투명 배경 + ClearTypeHint 를 <b>안쪽</b>에 둔다. Card 의 Effect(그림자)가
|
||||
중간 서피스를 만들어 ClearType 을 끄고, 그러면 13px 한글이 회색
|
||||
안티에일리어싱으로 뭉개진다. 힌트를 Effect 가 걸린 Border 에 걸면 듣지 않는다. -->
|
||||
<DockPanel Margin="14,12,14,12" Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled">
|
||||
<TextBlock DockPanel.Dock="Top" Text="치환 변수" Style="{StaticResource CardTitle}"
|
||||
Margin="0,0,0,10"/>
|
||||
|
||||
<!-- 검색: 돋보기를 겹쳐 둔다 — 빈 상자만 있으면 무엇을 하는 칸인지 안 읽힌다 -->
|
||||
<Grid DockPanel.Dock="Top" Margin="0,0,0,8">
|
||||
<!-- KeyDown 필수 — 확인 버튼이 IsDefault 라 이 칸의 Enter 가
|
||||
'저장하고 닫기'로 새어 나간다(변수를 못 넣은 채 편집이 끝난다) -->
|
||||
<TextBox x:Name="VariableSearch" Padding="28,4,8,4"
|
||||
VerticalContentAlignment="Center" TextChanged="OnVariableSearchChanged"
|
||||
KeyDown="OnVariableSearchKeyDown"
|
||||
AutomationProperties.Name="치환 변수 검색"
|
||||
ToolTip="변수명·설명·초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호)"/>
|
||||
<TextBlock Text="🔍" FontSize="11" Margin="9,0,0,0" IsHitTestVisible="False"
|
||||
@@ -439,6 +489,19 @@
|
||||
HorizontalContentAlignment="Stretch"
|
||||
ScrollViewer.HorizontalScrollBarVisibility="Disabled"
|
||||
AutomationProperties.Name="치환 변수 목록">
|
||||
<!--
|
||||
ListBox 는 템플릿 안에 ScrollViewer 를 품고 있고 그것이 중간 서피스다 —
|
||||
카드에 건 힌트는 <b>바깥</b>이라 목록 글자에는 듣지 않는다(표본 I).
|
||||
항목 패널을 불투명하게 만들어 힌트를 ScrollViewer <b>안쪽</b>에 둔다(표본 J).
|
||||
StackPanel 이 아니라 VirtualizingStackPanel 이어야 한다 —
|
||||
바꾸면 변수 120개가 전부 실체화된다.
|
||||
-->
|
||||
<ListBox.ItemsPanel>
|
||||
<ItemsPanelTemplate>
|
||||
<VirtualizingStackPanel Background="{DynamicResource B.Surface}"
|
||||
RenderOptions.ClearTypeHint="Enabled"/>
|
||||
</ItemsPanelTemplate>
|
||||
</ListBox.ItemsPanel>
|
||||
<ListBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DockPanel Margin="0,3" ToolTip="{Binding Token}">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user