diff --git a/src/SheetMe.Core/Catalog/SignatureImagePath.cs b/src/SheetMe.Core/Catalog/SignatureImagePath.cs new file mode 100644 index 0000000..3aa48ac --- /dev/null +++ b/src/SheetMe.Core/Catalog/SignatureImagePath.cs @@ -0,0 +1,84 @@ +namespace SheetMe.Core.Catalog; + +/// 이미지 경로를 어떻게 얻었는가 — 화면·진단이 사유를 정확히 말하기 위해 구분한다 +public enum SignatureImageOrigin +{ + /// 쓸 수 있는 파일이 없다 + None, + + /// 레거시 EMR 이 이미 내려받아 둔 캐시 파일 + LegacyCache, + + /// DB 가 준 경로 자체가 이 단말에서 바로 열린다 + DirectPath, +} + +/// 이미지 경로 판정 결과 +/// 어디서 얻었는가 +/// 열 수 있는 파일의 전체 경로 — 없으면 빈 문자열 +/// 원격 경로의 파일명(사유 문구·캐시 조회용) +public readonly record struct SignatureImageResult( + SignatureImageOrigin Origin, string LocalPath, string FileName); + +/// +/// DB 가 준 원격 경로에서 실제로 열 수 있는 로컬 파일을 정한다. +/// +/// 왜 이 규칙인가. 레거시는 원격 경로를 파일서버에서 내려받아 로컬 경로를 돌려준다 +/// (bzDataInterface.vb 싸인 17종 공통). 그 전송 계층은 이 병원 설정에서 ActiveX OCX +/// (AxBITSFTLib.AxbitSFT, M_DtlMst 'EMR_FileTransfer' Type=1)라 .NET 으로 재현할 수 없다 — +/// OCX 내부 프로토콜이 코드에 없고, 우리 배포는 서드파티·ActiveX 를 금지한다. +/// +/// 그래서 받지 않고 찾는다: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시 +/// ({설치경로}\Tmp\DownLoadedFiles\Signature\{파일명} — 레거시가 쓰는 바로 그 폴더), +/// ②DB 경로 자체가 이 단말에서 열리는 경우(로컬 절대경로·UNC 공유). 둘 다 아니면 없음이다. +/// +/// 이 판정을 순수 함수로 두는 이유는 DB·파일서버 없이 규칙을 검증하기 위해서다. +/// +public static class SignatureImagePath +{ + #region Methods + /// 레거시가 쓰는 캐시 폴더 — 설치 경로가 없으면 빈 문자열 + 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"); + + /// + /// 원격 경로 → 열 수 있는 로컬 파일. + /// + /// DB 가 준 경로(M_UidMst.UidImgPth 등) + /// HIS 설치 경로(ServerInfo ini 의 Install\Path) + /// 파일 존재 판정 — 테스트가 갈아 끼운다 + public static SignatureImageResult Resolve(string? remotePath, string? installPath, + Func? exists = 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); + } + } + // 캐시에 없으면 원격 경로가 이 단말에서 바로 열리는지 본다 — + // 이 병원의 저장 형태는 드라이브 절대경로다(진단 --db-filecfg 실측) + if (probe(remote)) + { + return new SignatureImageResult(SignatureImageOrigin.DirectPath, remote, fileName); + } + return new SignatureImageResult(SignatureImageOrigin.None, string.Empty, fileName); + } + #endregion +} diff --git a/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs b/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs index e0b2569..dc07738 100644 --- a/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs +++ b/src/SheetMe.Core/Catalog/TagPreviewCatalog.cs @@ -157,12 +157,16 @@ public static class TagPreviewCatalog { return TagPreviewKind.Table; } + // 싸인·서명·직인·로고 — 값은 이미지 파일 경로다. 환자 해석기가 경로를 얻어 + // 이 단말에서 파일을 찾고(레거시 캐시 → 경로 자체), PictureBox 가 그린다. + // 파일서버 전송(ActiveX)은 옮기지 않았으므로 캐시가 없는 단말에서는 사유가 된다. + // 분류는 PatientContext 다 — Image 로 두면 "글자만 보여 준다"는 틀린 사유가 나간다. if (tag.Contains("싸인", StringComparison.Ordinal) || tag.Contains("서명", StringComparison.Ordinal) || tag.Contains("직인", StringComparison.Ordinal) || tag.Contains("로고", StringComparison.Ordinal)) { - return TagPreviewKind.Image; + return TagPreviewKind.PatientContext; } return TagPreviewKind.PatientContext; } diff --git a/src/SheetMe.Data/Config/ServerInfoReader.cs b/src/SheetMe.Data/Config/ServerInfoReader.cs index fe85eed..d319227 100644 --- a/src/SheetMe.Data/Config/ServerInfoReader.cs +++ b/src/SheetMe.Data/Config/ServerInfoReader.cs @@ -103,6 +103,31 @@ public static class ServerInfoReader } } + /// + /// HIS 설치 경로 — ini 의 [MSYSTECHHIS\Install] Path(레거시 + /// GetInstallPathFromServerInfoFile, clsCommonLib.vb:271-287 과 같은 값). + /// 서명 이미지 캐시 폴더의 뿌리다. 이 값은 암호화되어 있지 않다(경로일 뿐). + /// ini 가 없거나 값이 비면 null — 레거시는 여기서 오류창을 띄우고 빈 문자열을 돌려줘 + /// 폴더가 루트 상대경로("\Tmp\...")가 되어 버린다. 그 결함은 옮기지 않는다. + /// + 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; + } + } + /// ini 경로 결정 — 명시 경로 우선, 없으면 실행 폴더에서 상위로 탐색([000]bin\SheetMe → [000]bin) private static string? ResolveIniPath(string? iniPath) { diff --git a/src/SheetMe.Data/Stores/SignatureStore.cs b/src/SheetMe.Data/Stores/SignatureStore.cs new file mode 100644 index 0000000..63d9892 --- /dev/null +++ b/src/SheetMe.Data/Stores/SignatureStore.cs @@ -0,0 +1,158 @@ +using Oracle.ManagedDataAccess.Client; + +namespace SheetMe.Data.Stores; + +/// +/// 싸인·직인·로고 태그가 쓰는 원격 이미지 경로 조회 — 레거시 bzDataInterface.vb 의 +/// 싸인 태그 17종에서 기계 추출했다. 태그 본문은 전부 같은 골격이다: +/// 경로를 DB 에서 한 칸 얻고 → 파일서버에서 내려받아 → 로컬 경로를 돌려준다. +/// +/// 여기서는 경로만 얻는다. 내려받는 계층(clsFileTransfer)은 이 병원 설정에서 +/// ActiveX OCX(M_DtlMst 'EMR_FileTransfer' Type=1)라 재현할 수 없다 — 그 판정과 +/// 대안(레거시 캐시 사용)은 에 있다. +/// +/// 대부분의 원문이 ORDER BY 없는 Rows(0) 이라 정렬을 명시해 한 행으로 고정했다 +/// (담당의 때 정한 규칙). 각 메서드 주석에 근거 줄번호를 남긴다. +/// +public sealed class SignatureStore +{ + #region Member Fields + private readonly string connectionString; + #endregion + + #region Constructors + public SignatureStore(string connectionString) + { + this.connectionString = connectionString; + } + #endregion + + #region Methods + /// + /// 수술 집도의 싸인(OCM_수술집도의_싸인, :3385) — 최신 수술(OprKey DESC)의 집도의 + /// (S_OprTyp 'DTRUIDCOD')를 오늘 기준 사용자·부서 마스터로 풀어 UidImgPth. + /// 원문의 OprStt 무필터·SYSDATE 유효기간을 그대로 옮긴다. + /// + 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)); + + /// + /// 수술 마취의사 싸인(OCM_수술마취의사싸인_수술접수연동, :3235) — 같은 골격에 + /// 오더 타입만 'ANEUIDCOD'(부서 조인 없음). + /// + 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)); + + /// + /// 마취과 의사 싸인(OCM_수술마취의사싸인, :3311) — 환자와 무관하게 마취과 소속 + /// 재직자 하나. 개원일 2021-03-01 이후 병원은 청구·그룹 코드로, 그 전은 부서명 LIKE + /// '%마취%' 로 찾는다(원문 주석: "코드로 하기엔 병원마다 다를거같아서"). + /// 원문에 ORDER BY 가 없어 임의 행이었다 — 사용자 코드 순 첫 행으로 고정한다. + /// + 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"); + + /// + /// 부서 코드로 찾는 의사 싸인 — 영상의학과(ETC_영상의학과의사싸인 :17353, 'XRAY')와 + /// 진단검사(ETC_진단검사의사싸인 :17578) 계열의 공통형. 기준일은 작업일(없으면 오늘). + /// 원문 ORDER BY 없음 → 사용자 코드 순 고정. + /// + 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)); + + /// + /// 외출·외박 승인자 싸인(ETC_외출외박_확인간호사싸인 :17683 / _확인원무과싸인 :17765) — + /// 신청 행의 승인자 코드(병동 OutAdmWrdAppUid / 원무 OutAdmOffAppUid)를 오늘 기준 + /// 사용자 마스터로 푼다. 원문 두 조회 모두 ORDER BY 없음 → 신청 시작일 최신으로 고정. + /// + 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)); + + /// + /// 병동 수간호사 싸인(ETC_로그인사용자_병동_수간호사_싸인, :15562) — 이름 태그와 같은 + /// 조회의 이미지 컬럼. 원문은 부서 코드를 문자열 연결로 넣는다 — 바인드로 정상화. + /// + 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())); + + /// + /// 병원 로고(ETC_요양기관명칭_병원로고, :16538) — M_HspMst.HspLgoPth, 무기한 유효 + /// (HspEndDte='29991231') 행. 원문은 0행이면 Rows(0) 예외였다 — 여기는 빈 값. + /// + public string HospitalLogo() + => Scalar( + "SELECT v FROM (SELECT HspLgoPth v FROM M_HspMst" + + " WHERE HspEndDte = '29991231' ORDER BY HspCod) WHERE ROWNUM = 1"); + + /// + /// 병원 직인(ETC_요양기관명칭_병원직인, :16580) — 병원 마스터가 아니라 + /// 설정 표(DB_REGISTRY, MnuNam='직인경로')의 SelYon 이다(원문에서 M_HspMst.HspStpPth + /// 조회는 주석 처리돼 있다). SCHILDREN·HSYUIL·JAH 는 즉시 빈 값 — 미이식 병원이다. + /// + public string HospitalStamp() + => Scalar( + "SELECT v FROM (SELECT SelYon v FROM DB_REGISTRY" + + " WHERE MnuNam = '직인경로' ORDER BY UidCod) WHERE ROWNUM = 1"); + + /// 스칼라 1칸 — 0행이거나 NULL 이면 빈 문자열 + 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 +} diff --git a/src/SheetMe.Data/Stores/UserContextStore.cs b/src/SheetMe.Data/Stores/UserContextStore.cs index d926bba..d8df2f8 100644 --- a/src/SheetMe.Data/Stores/UserContextStore.cs +++ b/src/SheetMe.Data/Stores/UserContextStore.cs @@ -25,7 +25,9 @@ public sealed record HisUser( /// 영문 이름(UidEngNam) — 진단서의사_영문의 로그인 의사 갈래 string EnglishName = "", /// 세부전문의번호(UidDtlSpcLic) — 진단서의사세부전문의번호 - string DetailSpecialistNumber = "") + string DetailSpecialistNumber = "", + /// 싸인 이미지 원격 경로(UidImgPth) — 로그인 사용자 싸인 계열 + string SignaturePath = "") { /// 미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다 public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty); @@ -85,7 +87,8 @@ public sealed class UserContextStore " 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(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 " + @@ -109,7 +112,8 @@ public sealed class UserContextStore 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(11).Trim(), reader.GetString(12).Trim(), reader.GetString(13).Trim(), + reader.GetString(14).Trim()); } #endregion } diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index af44f40..d55a498 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -223,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]); diff --git a/src/SheetMe.Designer/Controls/ControlTemplates.xaml b/src/SheetMe.Designer/Controls/ControlTemplates.xaml index 6852c97..8a94f54 100644 --- a/src/SheetMe.Designer/Controls/ControlTemplates.xaml +++ b/src/SheetMe.Designer/Controls/ControlTemplates.xaml @@ -229,13 +229,28 @@ - + - - - + + + + + + + + + diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index b0798da..a75de89 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -1570,6 +1570,113 @@ public static class DbSmoke } /// 테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용 + /// + /// 파일 전송 설정 진단(--db-filecfg) — 싸인·이미지 태그가 쓰는 전송 수단을 확정한다. + /// + /// 레거시 clsFileTransfer 는 단일 프로토콜이 아니라 5갈래이고 + /// (FileServer=1 · Database=2 · FileServerNDataBase=3 · SFTP=4 · FTP=5 · + /// LCTech_Ver_Windows=6 · LCTech_Ver_Linux=7, clsFileTransfer.vb:188-196) + /// 어느 갈래인지는 소스가 아니라 병원별 DB 설정이 정한다 + /// (M_DtlMst 'EMR_FileTransfer' 의 'Type', 기본값 3). + /// + /// 이식 가능성이 여기서 갈린다 — Database(2)만 순수 .NET 으로 재현할 수 있고, + /// FileServer 계열은 ActiveX OCX, SFTP 는 WinSCP 어셈블리라 우리 배포 규칙 + /// (서드파티 DLL 금지)과 충돌한다. + /// + /// 비밀값은 찍지 않는다 — 어떤 키가 있는지와 값의 길이만 남긴다. + /// + public static int RunFileTransferConfig(string reportPath) + { + var lines = new List(); + var failed = 0; + void Check(string title, bool ok, string detail = "") + { + lines.Add((ok ? "PASS " : "FAIL ") + title + (detail.Length > 0 ? $" — {detail}" : string.Empty)); + if (!ok) + { + failed++; + } + } + try + { + var config = ConfigService.Current; + using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); + connection.Open(); + + // ① 전송 모드 — 이 한 줄이 이식 가능성을 가른다 + var mode = string.Empty; + using (var command = connection.CreateCommand()) + { + command.CommandText = + "SELECT DtlCod, DtlCodNam FROM M_DtlMst WHERE DtlTblCod = 'EMR_FileTransfer' ORDER BY DtlCod"; + using var reader = command.ExecuteReader(); + while (reader.Read()) + { + var key = reader.GetValue(0)?.ToString()?.Trim() ?? string.Empty; + var value = reader.IsDBNull(1) ? string.Empty : reader.GetValue(1).ToString()!.Trim(); + // Type 만 값을 찍는다 — 나머지(IP·Port·User·Pwd)는 접속 정보라 길이만 + lines.Add(string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase) + ? $" 설정 {key} = {value}" + : $" 설정 {key} = ({value.Length}자, 값은 기록하지 않는다)"); + if (string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase)) + { + mode = value; + } + } + } + var modeName = mode switch + { + "1" => "FileServer(ActiveX OCX)", + "2" => "Database(E_FilInf BLOB)", + "3" => "FileServerNDataBase(ActiveX OCX)", + "4" => "SFTP(WinSCP 어셈블리)", + "5" => "FTP(다운로드 분기 없음 — 항상 실패)", + "6" => "LCTech_Windows(ActiveX OCX)", + "7" => "LCTech_Linux(ActiveX OCX)", + "" => "설정 없음 → 레거시 기본값 3(FileServerNDataBase, ActiveX OCX)", + _ => $"알 수 없는 값({mode})", + }; + lines.Add($" 전송 모드: {modeName}"); + Check("① 전송 설정을 읽는다", true); + + // ② 이미지 원본이 DB 에 있는가 — Database 모드면 여기 실체가 있다 + var filCount = FirstText(config.ConnectionString, "SELECT COUNT(*) FROM E_FilInf"); + lines.Add(filCount.Length == 0 + ? " E_FilInf: 없음(ORA-00942) — DB 보관 경로가 이 DB 엔 없다" + : $" E_FilInf: {filCount}행"); + + // ③ 서명 경로가 실제로 채워져 있는가 — 태그가 값을 낼 수 있는 환자가 있는지 + var signed = FirstText(config.ConnectionString, + "SELECT COUNT(*) FROM M_UidMst WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' '"); + var sample = FirstText(config.ConnectionString, + "SELECT v FROM (SELECT UidImgPth v FROM M_UidMst" + + " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ' ORDER BY UidCod) WHERE ROWNUM = 1"); + lines.Add($" M_UidMst.UidImgPth 채워진 행: {signed}"); + // 경로 모양만 남긴다 — 사람 이름이 섞일 수 있어 값 자체는 안 찍는다 + var shape = sample.Length == 0 ? "없음" + : sample.StartsWith(@"\\", StringComparison.Ordinal) ? "UNC 공유(\\\\서버\\...)" + : sample.Contains(':') ? "드라이브 절대경로(C:\\...)" + : sample.Contains('\\') ? "역슬래시 상대경로" + : sample.Contains('/') ? "슬래시 상대경로" : "구분자 없음"; + lines.Add($" 경로 모양: {shape} (길이 {sample.Length}자, 값은 기록하지 않는다)"); + + // ④ 병원 로고·직인도 같은 축이다 + var logo = FirstText(config.ConnectionString, + "SELECT COUNT(*) FROM M_HspMst WHERE HspLgoPth IS NOT NULL AND TRIM(HspLgoPth) <> ' '"); + lines.Add($" M_HspMst.HspLgoPth 채워진 행: {(logo.Length == 0 ? "컬럼 없음" : logo)}"); + Check("② 이미지 원본·경로 실태를 읽는다", true); + } + catch (Exception ex) + { + Check("① 전송 설정을 읽는다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); + } + lines.Add(string.Empty); + lines.Add(failed == 0 ? "결과: 전건 통과" : $"결과: 실패 {failed}건"); + System.IO.File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + Console.WriteLine(string.Join(Environment.NewLine, lines)); + return failed == 0 ? 0 : 1; + } + public static int RunSample(string tableName, string reportPath, int max = 12) { try @@ -2387,6 +2494,59 @@ public static class DbSmoke "RPAD 매칭이나 NATCOD 조인을 의심할 것"); } + // ㊲ 싸인·직인·로고 경로 조회 — 파일서버 전송(ActiveX)은 + // 옮기지 않았지만 경로 조회는 전부 순수 SQL이다. + // 컬럼·조인 이름이 틀리면 여기서 죽는다. + try + { + var signs = new SignatureStore(conn); + var probes = new (string Name, Func Run)[] + { + ("집도의", () => owner2 is null ? "-" : signs.SurgeonSignature(owner2.Value).Length.ToString()), + ("마취(수술)", () => owner2 is null ? "-" : signs.AnesthesiaSignatureBySurgery(owner2.Value).Length.ToString()), + ("마취(부서)", () => signs.AnesthesiaDepartmentSignature(true).Length.ToString()), + ("영상의학", () => signs.DepartmentDoctorSignature("XRAY", "20260820").Length.ToString()), + ("진단검사", () => signs.DepartmentDoctorSignature("LAB", "20260820").Length.ToString()), + ("외출승인", () => owner2 is null ? "-" : signs.OutingApproverSignature(owner2.Value, false).Length.ToString()), + ("수간호사", () => signs.WardHeadNurseSignature("0506").Length.ToString()), + ("병원로고", () => signs.HospitalLogo().Length.ToString()), + ("병원직인", () => signs.HospitalStamp().Length.ToString()), + }; + var signShapes = new List(); + var failedSigns = new List(); + foreach (var (signName, run) in probes) + { + try + { + signShapes.Add($"{signName} {run()}"); + } + catch (Exception ex) + { + var first = ex.Message.Split('\n')[0]; + if (first.Contains("ORA-00942")) + { + signShapes.Add($"{signName} [표없음]"); + } + else + { + failedSigns.Add($"{signName}: {first}"); + } + } + } + lines.Add($" 싸인 경로: {string.Join(" · ", signShapes)} (값은 기록하지 않는다)"); + foreach (var f in failedSigns) + { + lines.Add($" [진단] {f}"); + } + Check("㊲ 싸인·직인·로고 경로 조회가 예외 없이 돈다", failedSigns.Count == 0, + "위 [진단] 줄의 컬럼·조인 이름을 의심할 것"); + } + catch (Exception ex) + { + Check("㊲ 싸인·직인·로고 경로 조회가 예외 없이 돈다", false, + $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); + } + // 나이 UDF — 주민번호가 있는 차트로 물으면 값이 나와야 한다. // (통합 대표행의 AgeMonth/AGE 파생 컬럼과 같은 식 — // UDF 이름·접두가 틀리면 여기서 바로 죽는다) diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs index 92f4a14..14014c8 100644 --- a/src/SheetMe.Designer/Services/PatientIdentity.cs +++ b/src/SheetMe.Designer/Services/PatientIdentity.cs @@ -204,6 +204,16 @@ public static class PatientIdentity return () => connection.Length == 0 ? null : new PatientExtraStore(connection); } + /// 싸인·이미지 경로 조회 공급자 — 파일서버 전송은 옮기지 않았고 경로만 얻는다 + public static Func SignatureStoreSource() + { + var connection = ConfigService.Current.ConnectionString; + return () => connection.Length == 0 ? null : new SignatureStore(connection); + } + + /// HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path) + public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath(); + /// 문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용 public static Func ContextStoreSource() { diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs index 8a7b25f..93e80d5 100644 --- a/src/SheetMe.Designer/Services/PatientSession.cs +++ b/src/SheetMe.Designer/Services/PatientSession.cs @@ -76,7 +76,8 @@ public static class PatientSession PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), - PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked)) + PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked), + PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath()) : session; return (tags, new MDataTableRunner(document, Variables())); } diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs index 9748cfb..7152f09 100644 --- a/src/SheetMe.Designer/Services/PatientTagResolver.cs +++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs @@ -228,6 +228,12 @@ public sealed class PatientTagResolver : ITagValueResolver private readonly Dictionary> specialDoctorCache = new(StringComparer.Ordinal); + /// 싸인·이미지 경로 조회 공급자 — 태그가 물을 때 만든다 + private readonly Func? signatureStoreSource; + + /// HIS 설치 경로 — 레거시 싸인 캐시 폴더의 뿌리(ServerInfo ini) + private readonly string? installPath; + /// 환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다 private readonly Func? extraStoreSource; @@ -265,7 +271,9 @@ public sealed class PatientTagResolver : ITagValueResolver Func? extraStoreSource = null, Func? contextStoreSource = null, Func? bstSource = null, - Func? chartVitalSource = null) + Func? chartVitalSource = null, + Func? signatureStoreSource = null, + string? installPath = null) { this.context = context; this.next = next; @@ -286,6 +294,8 @@ public sealed class PatientTagResolver : ITagValueResolver this.contextStoreSource = contextStoreSource; this.bstSource = bstSource; this.chartVitalSource = chartVitalSource; + this.signatureStoreSource = signatureStoreSource; + this.installPath = installPath; } #endregion @@ -357,6 +367,10 @@ public sealed class PatientTagResolver : ITagValueResolver { return ageValue; } + if (FromSignature(tag) is { } signValue) + { + return signValue; + } if (Computed.TryGetValue(tag, out var compute)) { var made = compute(context); @@ -1025,6 +1039,134 @@ public sealed class PatientTagResolver : ITagValueResolver : new TagValue(false, "수술일자 값이 형식에 맞지 않습니다"); } + /// 가 담당하는 이미지 태그 17종 + private static readonly HashSet SignatureTags = new(StringComparer.Ordinal) + { + "OCM_담당의사싸인", "OCM_진단서의사싸인", "OCM_로그인_사용자싸인", + "ETC_로그인사용자_서명", "ECT_로그인사용자싸인", + "OCM_수술집도의_싸인", "OCM_수술마취의사싸인", "OCM_수술마취의사싸인_수술접수연동", + "OCM_Cosign_싸인", "ETC_병리판독의사싸인", "ETC_영상의학과의사싸인", "ETC_진단검사의사싸인", + "ETC_요양기관명칭_병원로고", "ETC_요양기관명칭_병원직인", + "ETC_외출외박_확인간호사싸인", "ETC_외출외박_확인원무과싸인", + "ETC_로그인사용자_병동_수간호사_싸인", + }; + + /// + /// 싸인·직인·로고 17종 — DB 는 경로만 준다. 레거시는 그 경로를 파일서버에서 + /// 내려받아 로컬 경로를 돌려주지만, 이 병원 설정의 전송 계층은 ActiveX OCX + /// (M_DtlMst 'EMR_FileTransfer' Type=1 — 진단 --db-filecfg 실측)라 + /// 프로토콜이 코드에 없어 .NET 으로 재현할 수 없다(서드파티·ActiveX 배포 금지이기도 하다). + /// + /// 그래서 받지 않고 찾는다: 레거시 EMR 이 같은 단말에 이미 받아 둔 캐시 + /// 폴더를 먼저 보고, 없으면 DB 경로 자체가 열리는지 본다 + /// (). 둘 다 없으면 사유 — 종이에는 빈칸이다. + /// + /// 값(경로)이 나오면 PictureBox 가 그 파일을 그린다(PrintService.WithTagValue). + /// + private TagValue? FromSignature(string tag) + { + if (!SignatureTags.Contains(tag)) + { + return null; + } + if (extraCache.TryGetValue(tag, out var cached)) + { + return cached; + } + var value = Compute(); + extraCache[tag] = value; + return value; + + TagValue Compute() + { + // ① 원격 경로를 얻는다 — 여기까지는 전부 순수 SQL 이라 이식이 끝난다 + string remote; + try + { + if (RemotePath(tag) is not { } path) + { + return new TagValue(false, "이 태그의 이미지 경로 조회가 연결되지 않았습니다"); + } + remote = path; + } + catch (Exception ex) + { + return new TagValue(false, $"이미지 경로 조회에 실패했습니다: {ex.GetType().Name}"); + } + if (remote.Length == 0) + { + return new TagValue(false, "이 태그가 가리키는 이미지 경로가 등록되어 있지 않습니다"); + } + + // ② 그 경로의 파일을 이 단말에서 찾는다(레거시 캐시 → 경로 자체) + var found = SignatureImagePath.Resolve(remote, installPath); + return found.Origin switch + { + SignatureImageOrigin.None => new TagValue(false, + $"이미지 파일을 이 단말에서 찾지 못했습니다({found.FileName})" + + " — 파일서버 전송(ActiveX)은 옮기지 않았습니다"), + _ => new TagValue(true, found.LocalPath), + }; + } + } + + /// 태그별 원격 경로 — 조회가 연결되지 않았으면 null + private string? RemotePath(string tag) + { + // 로그인 사용자 계열 3종은 세션이 이미 들고 있다(왕복 0) + if (tag is "OCM_로그인_사용자싸인" or "ETC_로그인사용자_서명" or "ECT_로그인사용자싸인") + { + return UserSession.Current.SignaturePath; + } + // 담당의는 프리페치한 행에 있다(왕복 0) + if (tag == "OCM_담당의사싸인") + { + return doctor.TryGetValue("UidImgPth", out var v) ? v.Trim() : string.Empty; + } + // 진단서 의사 = 로그인 의사면 본인, 아니면 담당의(:4904-5017 — 이름 계열과 같은 분기) + if (tag == "OCM_진단서의사싸인") + { + return UserSession.Current.DoctorYn == "Y" + ? UserSession.Current.SignaturePath + : doctor.TryGetValue("UidImgPth", out var v) ? v.Trim() : string.Empty; + } + // 레거시 확정 결함 — 조회 SELECT 에 UidNam 만 있는데 UidImgPth 를 읽어 + // 항상 예외→빈 값이었다(:17434-17513). 고치면 없던 싸인이 갑자기 찍힌다. + if (tag == "ETC_병리판독의사싸인") + { + return string.Empty; + } + // 저장된 기록 키(EmrKey)가 필요한 태그 — 미리보기에는 그 키가 없다 + if (tag == "OCM_Cosign_싸인") + { + return string.Empty; + } + if (signatureStoreSource?.Invoke() is not { } store) + { + return null; + } + var comNum = PatientContext.Value(context.ComInf, "ComNum"); + var hasVisit = decimal.TryParse(comNum, out var m); + var today = PatientContext.Value(hospital, "ServerToday"); + var open = PatientContext.Value(hospital, "HspStrDte"); + return tag switch + { + "OCM_수술집도의_싸인" => hasVisit ? store.SurgeonSignature(m) : string.Empty, + "OCM_수술마취의사싸인_수술접수연동" => hasVisit ? store.AnesthesiaSignatureBySurgery(m) : string.Empty, + // 개원일 2021-03-01 이후면 코드로, 그 전이면 부서명 LIKE(:3311-3384) + "OCM_수술마취의사싸인" => store.AnesthesiaDepartmentSignature( + string.CompareOrdinal(open, "20210301") > 0), + "ETC_영상의학과의사싸인" => store.DepartmentDoctorSignature("XRAY", today), + "ETC_진단검사의사싸인" => store.DepartmentDoctorSignature("LAB", today), + "ETC_외출외박_확인간호사싸인" => hasVisit ? store.OutingApproverSignature(m, false) : string.Empty, + "ETC_외출외박_확인원무과싸인" => hasVisit ? store.OutingApproverSignature(m, true) : string.Empty, + "ETC_로그인사용자_병동_수간호사_싸인" => store.WardHeadNurseSignature(UserSession.Current.DepCod), + "ETC_요양기관명칭_병원로고" => store.HospitalLogo(), + "ETC_요양기관명칭_병원직인" => store.HospitalStamp(), + _ => string.Empty, + }; + } + /// /// 나이 계열 7종(bzDataInterface.vb:359-508 + AgeCheck clsCommonLib.vb:665-974). /// @@ -1742,6 +1884,14 @@ public sealed class PatientTagResolver : ITagValueResolver "ETC_승인의사", "OCM_수술소독간호사", "OCM_적용진료과", "OCM_적용진료의사", "OCM_전과", "OCM_간호정보조사지_과거력", "OCM_영상촬영이력", + // 싸인·직인·로고 17종 — 값은 이미지 파일 경로다(FromSignature) + "OCM_담당의사싸인", "OCM_진단서의사싸인", "OCM_로그인_사용자싸인", + "ETC_로그인사용자_서명", "ECT_로그인사용자싸인", + "OCM_수술집도의_싸인", "OCM_수술마취의사싸인", "OCM_수술마취의사싸인_수술접수연동", + "OCM_Cosign_싸인", "ETC_병리판독의사싸인", "ETC_영상의학과의사싸인", "ETC_진단검사의사싸인", + "ETC_요양기관명칭_병원로고", "ETC_요양기관명칭_병원직인", + "ETC_외출외박_확인간호사싸인", "ETC_외출외박_확인원무과싸인", + "ETC_로그인사용자_병동_수간호사_싸인", "OCM_수술주상병", "OCM_수술집도의_진료과", "OCM_수술집도의_그룹진료과", "OCM_수술명칭", "OCM_수술진단명", "OCM_수술처치", "OCM_퇴원약_코드", "OCM_퇴원약_명칭", "OCM_퇴원약_용량", "OCM_퇴원약_횟수", diff --git a/src/SheetMe.Designer/Services/PrintService.cs b/src/SheetMe.Designer/Services/PrintService.cs index 6cf9aa6..1e87809 100644 --- a/src/SheetMe.Designer/Services/PrintService.cs +++ b/src/SheetMe.Designer/Services/PrintService.cs @@ -218,6 +218,19 @@ public static class PrintService { return control; } + // 이미지류(싸인·직인·로고)의 값은 파일 경로다 — 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) { diff --git a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs index 1c0b80f..e59bc3a 100644 --- a/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs +++ b/src/SheetMe.Designer/ViewModels/Controls/TypedControlViewModels.cs @@ -180,6 +180,59 @@ public sealed class PictureBoxViewModel : ControlViewModel /// 디자인 표시명 — DataInterfaceTag(런타임 바인딩 이미지) 힌트 public string Hint => Prop("DataInterfaceTag") ?? "이미지"; + /// + /// 그릴 이미지 — 해석기가 정한 파일 경로(ResolvedImagePath)를 읽는다. + /// 캔버스(편집 화면)에는 그 속성이 없어 null 이고 자리표시자가 남는다. + /// + /// 파일을 잠그지 않는다(OnLoad + 스트림 복사). 레거시도 같은 이유로 + /// Image.FromFile 대신 바이트를 읽어 메모리에서 디코딩한다(MPictureBox.vb:429-572) — + /// 잠그면 그 파일을 쓰는 다른 프로그램이 막힌다. + /// 못 읽으면 null 이다(깨진 파일·권한) — 레거시도 빈 catch 로 삼켜 빈칸이 됐다. + /// + 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; + } + } + } + + /// + /// 채우기 방식 — 레거시 SizeMode 대응. + /// + /// 인쇄는 레거시가 SizeMode 를 무시하고 컨트롤 사각형에 늘려 그린다 + /// (MPictureBox.vb:707-783 PrintMe/DrawingControl). 종이를 레거시와 맞추는 것이 + /// 이 프로그램의 기준이므로 기본을 Fill 로 두고, 비율 유지를 명시한 값만 존중한다. + /// + 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, + }; + + /// 이미지가 없을 때만 자리표시자를 보인다 + public bool HasImage => Image is not null; + public PictureBoxViewModel(ControlElement model) : base(model) { } } diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs index 82d9c16..5c59da8 100644 --- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs +++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs @@ -233,7 +233,8 @@ public partial class PreviewWindow : Window PatientIdentity.ScheduledSurgerySourceFor(picked), PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(), PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(), - PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked)); + PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked), + PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath()); // 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아 // 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다. fields = new MDataTableRunner(designer.Document, diff --git a/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs b/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs new file mode 100644 index 0000000..ee17e54 --- /dev/null +++ b/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs @@ -0,0 +1,78 @@ +using SheetMe.Core.Catalog; + +namespace SheetMe.Core.Tests; + +/// +/// 싸인 이미지 경로 판정 — 파일서버 전송(ActiveX)을 옮기지 않았으므로 +/// "받지 않고 찾는" 규칙이 값의 유무를 가른다. DB·파일 없이 검증한다. +/// +[TestClass] +public sealed class SignatureImagePathTests +{ + #region Methods + private const string Install = @"C:\MsystechHIS_Ver.2"; + private const string Remote = @"C:\Sign\Server\hong.tif"; + private static readonly string Cached = @"C:\MsystechHIS_Ver.2\Tmp\DownLoadedFiles\Signature\hong.tif"; + + [TestMethod] + public void 레거시가_받아둔_캐시가_있으면_그것을_쓴다() + { + var result = SignatureImagePath.Resolve(Remote, Install, p => p == Cached); + Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin); + Assert.AreEqual(Cached, result.LocalPath); + Assert.AreEqual("hong.tif", result.FileName); + } + + [TestMethod] + public void 캐시가_없으면_DB_경로_자체를_본다() + { + var result = SignatureImagePath.Resolve(Remote, Install, p => p == Remote); + Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin); + Assert.AreEqual(Remote, result.LocalPath); + } + + [TestMethod] + public void 캐시가_원격보다_먼저다() + { + // 둘 다 있으면 캐시 — 레거시가 그 폴더에 받아 두고 그 경로를 돌려준다 + var result = SignatureImagePath.Resolve(Remote, Install, _ => true); + Assert.AreEqual(SignatureImageOrigin.LegacyCache, result.Origin); + } + + [TestMethod] + public void 둘_다_없으면_없음이고_파일명은_남는다() + { + // 파일명은 사유 문구에 쓴다 — "무엇을 못 찾았는지" 를 말할 수 있어야 한다 + var result = SignatureImagePath.Resolve(Remote, Install, _ => false); + Assert.AreEqual(SignatureImageOrigin.None, result.Origin); + Assert.AreEqual(string.Empty, result.LocalPath); + Assert.AreEqual("hong.tif", result.FileName); + } + + [TestMethod] + public void 경로가_비면_아무것도_찾지_않는다() + { + var result = SignatureImagePath.Resolve(" ", Install, _ => true); + Assert.AreEqual(SignatureImageOrigin.None, result.Origin); + Assert.AreEqual(string.Empty, result.FileName); + } + + [TestMethod] + public void 설치경로가_없으면_캐시를_보지_않는다() + { + // 레거시는 설치 경로가 없으면 "\Tmp\..." 라는 루트 상대경로를 만든다 — 그 결함은 안 옮긴다 + Assert.AreEqual(string.Empty, SignatureImagePath.CacheFolder(null)); + var result = SignatureImagePath.Resolve(Remote, null, p => p == Remote); + Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin); + } + + [TestMethod] + public void UNC_공유_경로도_파일명을_뗀다() + { + var unc = @"\\fileserver\sign\kim.jpg"; + var result = SignatureImagePath.Resolve(unc, Install, p => p == unc); + Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin); + Assert.AreEqual("kim.jpg", result.FileName); + } + #endregion +} diff --git a/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs b/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs index 4ad4b9e..d85e50c 100644 --- a/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs +++ b/tests/SheetMe.Core.Tests/TagPreviewCatalogTests.cs @@ -48,10 +48,13 @@ public sealed class TagPreviewCatalogTests } [TestMethod] - public void 이미지_경로_태그는_따로_가른다() + public void 이미지_경로_태그도_환자_문맥이다() { - Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인")); - Assert.AreEqual(TagPreviewKind.Image, TagPreviewCatalog.KindOf("ETC_병원직인")); + // 2026-08-20: 싸인·직인·로고를 이식하면서 분류를 옮겼다. + // 값은 '이미지 파일 경로'이고 환자 해석기가 그것을 얻어 PictureBox 가 그린다 — + // Image 로 두면 "글자만 보여 준다"는 틀린 사유가 화면에 나간다. + Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ECT_로그인사용자싸인")); + Assert.AreEqual(TagPreviewKind.PatientContext, TagPreviewCatalog.KindOf("ETC_병원직인")); } [TestMethod]