diff --git a/src/SheetMe.Core/Catalog/SignatureImagePath.cs b/src/SheetMe.Core/Catalog/SignatureImagePath.cs
index 3aa48ac..976120e 100644
--- a/src/SheetMe.Core/Catalog/SignatureImagePath.cs
+++ b/src/SheetMe.Core/Catalog/SignatureImagePath.cs
@@ -9,6 +9,9 @@ public enum SignatureImageOrigin
/// 레거시 EMR 이 이미 내려받아 둔 캐시 파일
LegacyCache,
+ /// 파일서버의 공유 폴더(UNC)로 직접 읽었다
+ Share,
+
/// DB 가 준 경로 자체가 이 단말에서 바로 열린다
DirectPath,
}
@@ -51,8 +54,12 @@ public static class SignatureImagePath
/// DB 가 준 경로(M_UidMst.UidImgPth 등)
/// HIS 설치 경로(ServerInfo ini 의 Install\Path)
/// 파일 존재 판정 — 테스트가 갈아 끼운다
+ ///
+ /// 서버 경로 → UNC 공유 대응("D:\MsystechHIS=\\서버\MsystechHIS" 형태).
+ /// 접두가 긴 것부터 맞춰 본다 — 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다.
+ ///
public static SignatureImageResult Resolve(string? remotePath, string? installPath,
- Func? exists = null)
+ Func? exists = null, IReadOnlyList? shareMap = null)
{
var remote = (remotePath ?? string.Empty).Trim();
if (remote.Length == 0)
@@ -72,13 +79,54 @@ public static class SignatureImagePath
return new SignatureImageResult(SignatureImageOrigin.LegacyCache, cached, fileName);
}
}
- // 캐시에 없으면 원격 경로가 이 단말에서 바로 열리는지 본다 —
- // 이 병원의 저장 형태는 드라이브 절대경로다(진단 --db-filecfg 실측)
+ // 캐시에 없으면 파일서버 공유(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);
}
+
+ ///
+ /// 서버 경로를 UNC 로 바꾼다 — 대응이 없으면 빈 문자열.
+ /// 대소문자를 무시하고, 접두가 긴 것부터 맞춘다.
+ ///
+ public static string MapToShare(string? remotePath, IReadOnlyList? 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
}
diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs
index 43e06de..272b46f 100644
--- a/src/SheetMe.Data/Config/DataConfig.cs
+++ b/src/SheetMe.Data/Config/DataConfig.cs
@@ -43,6 +43,20 @@ public sealed class DataConfig
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
///
public string DevUidCod { get; set; } = string.Empty;
+
+ ///
+ /// 싸인·직인 이미지의 서버 경로 → UNC 공유 대응 — "서버접두=UNC접두" 목록.
+ /// 예: D:\MsystechHIS=\\192.168.100.222\MsystechHIS
+ ///
+ /// 왜 설정인가. DB 는 이미지 위치를 파일서버의 로컬 절대경로로 들고 있고
+ /// (실측: D:\MsystechHIS\{사이트}\…), 레거시는 그 경로를 ActiveX OCX 데몬
+ /// (기본 포트 2002)에 넘겨 받아 온다. 그 프로토콜은 재현할 수 없으므로 우리는 같은 파일을
+ /// 공유 폴더로 직접 읽는다 — 접두 대응은 병원마다 다르니 코드가 아니라 설정이다.
+ ///
+ /// 비어 있으면(기본) 매핑을 시도하지 않는다 — 지금까지와 같이 로컬 캐시만 본다.
+ /// 자격증명은 담지 않는다: 공유 접근 권한은 단말 로그인 계정으로 해결한다.
+ ///
+ public string[] SignatureShareMap { get; set; } = Array.Empty();
#endregion
#region Methods
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index a75de89..9ffe707 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -1614,10 +1614,13 @@ public static class DbSmoke
{
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}자, 값은 기록하지 않는다)");
+ // 접속 대상(Type·IP·Port·FileDirectory)은 값을 찍는다 — SMB 직접 접근
+ // 가능성을 판단하려면 주소와 공유 이름이 필요하다.
+ // 자격증명(User·Pwd)은 길이만 — 값은 어디에도 남기지 않는다.
+ var secret = key is "Pwd" or "User" or "Password" or "Account";
+ lines.Add(secret
+ ? $" 설정 {key} = ({value.Length}자, 값은 기록하지 않는다)"
+ : $" 설정 {key} = {value}");
if (string.Equals(key, "Type", StringComparison.OrdinalIgnoreCase))
{
mode = value;
@@ -1652,6 +1655,23 @@ public static class DbSmoke
"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}");
+ // 폴더 구조는 개인정보가 아니다 — 파일명(사용자 코드가 섞일 수 있다)만 뗀다.
+ // 이 접두가 FileDirectory(서버측 루트)와 어떻게 대응하는지가 SMB 매핑의 근거다.
+ using (var command = connection.CreateCommand())
+ {
+ command.CommandText =
+ "SELECT folder, COUNT(*) c FROM (SELECT SUBSTR(UidImgPth, 1,"
+ + " INSTR(UidImgPth, '\\', -1)) folder FROM M_UidMst"
+ + " WHERE UidImgPth IS NOT NULL AND TRIM(UidImgPth) <> ' ')"
+ + " GROUP BY folder ORDER BY COUNT(*) DESC";
+ using var reader = command.ExecuteReader();
+ var shown = 0;
+ while (reader.Read() && shown < 5)
+ {
+ lines.Add($" 경로 접두: {reader.GetValue(0)} ({reader.GetValue(1)}건)");
+ shown++;
+ }
+ }
// 경로 모양만 남긴다 — 사람 이름이 섞일 수 있어 값 자체는 안 찍는다
var shape = sample.Length == 0 ? "없음"
: sample.StartsWith(@"\\", StringComparison.Ordinal) ? "UNC 공유(\\\\서버\\...)"
@@ -1660,7 +1680,58 @@ public static class DbSmoke
: sample.Contains('/') ? "슬래시 상대경로" : "구분자 없음";
lines.Add($" 경로 모양: {shape} (길이 {sample.Length}자, 값은 기록하지 않는다)");
- // ④ 병원 로고·직인도 같은 축이다
+ // ④ 공유(SMB) 직접 접근이 실제로 되는가 — OCX 전송을 대신할 유일한 경로다.
+ // 설정이 없으면 "무엇을 설정해야 하는지"를 알려 준다.
+ var map = config.SignatureShareMap;
+ if (map.Length == 0)
+ {
+ lines.Add(" 공유 매핑: 설정 없음(Images:SignatureShareMap)");
+ lines.Add(" → 예: \"D:\\MsystechHIS=\\\\<파일서버>\\<공유이름>\" (서버 경로 접두 = UNC 접두)");
+ }
+ else
+ {
+ foreach (var entry in map)
+ {
+ var at = entry.IndexOf('=', StringComparison.Ordinal);
+ var unc = at > 0 ? entry[(at + 1)..].Trim() : string.Empty;
+ var reachable = unc.Length > 0 && System.IO.Directory.Exists(unc);
+ lines.Add($" 공유 매핑: {entry} → 접근 {(reachable ? "가능" : "불가")}");
+ }
+ // 실제 경로 하나로 끝까지 확인한다 — 매핑이 맞아도 파일이 없으면 소용없다
+ if (sample.Length > 0)
+ {
+ var mapped = SheetMe.Core.Catalog.SignatureImagePath.MapToShare(sample, map);
+ lines.Add(mapped.Length == 0
+ ? " 표본 경로: 매핑되는 접두가 없다(접두를 실제 저장 경로에 맞출 것)"
+ : $" 표본 경로 매핑 결과: 파일 {(System.IO.File.Exists(mapped) ? "있음" : "없음")}"
+ + " (경로는 기록하지 않는다)");
+ }
+ }
+ // 파일서버가 SMB 를 열어 두었는지 — 닫혀 있으면 매핑을 아무리 맞춰도 안 된다.
+ // 주소는 접속 설정의 IP 를 그대로 쓴다(자격증명은 쓰지 않는다 — 단말 계정으로 붙는다).
+ var serverIp = FirstText(config.ConnectionString,
+ "SELECT DtlCodNam FROM M_DtlMst WHERE DtlTblCod = 'EMR_FileTransfer' AND DtlCod = 'IP'");
+ if (serverIp.Length > 0)
+ {
+ var open = false;
+ try
+ {
+ using var probe = new System.Net.Sockets.TcpClient();
+ open = probe.ConnectAsync(serverIp, 445).Wait(TimeSpan.FromSeconds(3));
+ }
+ catch
+ {
+ open = false;
+ }
+ lines.Add($" 파일서버 {serverIp} SMB(445): {(open ? "열림" : "닫힘")}");
+ if (!open)
+ {
+ lines.Add(" → 서버에서 SMB 공유를 열어야 이미지 직접 읽기가 가능하다"
+ + "(전송 데몬 포트만 열려 있으면 우리는 못 받는다)");
+ }
+ }
+
+ // ⑤ 병원 로고·직인도 같은 축이다
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)}");
diff --git a/src/SheetMe.Designer/Services/ConfigLoader.cs b/src/SheetMe.Designer/Services/ConfigLoader.cs
index f25af14..eb1d04e 100644
--- a/src/SheetMe.Designer/Services/ConfigLoader.cs
+++ b/src/SheetMe.Designer/Services/ConfigLoader.cs
@@ -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;
}
diff --git a/src/SheetMe.Designer/Services/PatientIdentity.cs b/src/SheetMe.Designer/Services/PatientIdentity.cs
index 14014c8..4c7ef11 100644
--- a/src/SheetMe.Designer/Services/PatientIdentity.cs
+++ b/src/SheetMe.Designer/Services/PatientIdentity.cs
@@ -214,6 +214,9 @@ public static class PatientIdentity
/// HIS 설치 경로 — 레거시 싸인 캐시 폴더(ServerInfo ini 의 Install\Path)
public static string? InstallPath() => SheetMe.Data.Config.ServerInfoReader.InstallPath();
+ /// 파일서버 공유 매핑 — 설정(Images:SignatureShareMap)
+ public static IReadOnlyList ShareMap() => ConfigService.Current.SignatureShareMap;
+
/// 문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용
public static Func ContextStoreSource()
{
diff --git a/src/SheetMe.Designer/Services/PatientSession.cs b/src/SheetMe.Designer/Services/PatientSession.cs
index 93e80d5..eaf98a7 100644
--- a/src/SheetMe.Designer/Services/PatientSession.cs
+++ b/src/SheetMe.Designer/Services/PatientSession.cs
@@ -77,7 +77,8 @@ public static class PatientSession
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
- PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath())
+ PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
+ PatientIdentity.ShareMap())
: session;
return (tags, new MDataTableRunner(document, Variables()));
}
diff --git a/src/SheetMe.Designer/Services/PatientTagResolver.cs b/src/SheetMe.Designer/Services/PatientTagResolver.cs
index 7152f09..27f3774 100644
--- a/src/SheetMe.Designer/Services/PatientTagResolver.cs
+++ b/src/SheetMe.Designer/Services/PatientTagResolver.cs
@@ -234,6 +234,9 @@ public sealed class PatientTagResolver : ITagValueResolver
/// HIS 설치 경로 — 레거시 싸인 캐시 폴더의 뿌리(ServerInfo ini)
private readonly string? installPath;
+ /// 파일서버 공유 매핑(서버경로=UNC) — 설정에서 온다
+ private readonly IReadOnlyList? shareMap;
+
/// 환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다
private readonly Func? extraStoreSource;
@@ -273,7 +276,8 @@ public sealed class PatientTagResolver : ITagValueResolver
Func? bstSource = null,
Func? chartVitalSource = null,
Func? signatureStoreSource = null,
- string? installPath = null)
+ string? installPath = null,
+ IReadOnlyList? shareMap = null)
{
this.context = context;
this.next = next;
@@ -296,6 +300,7 @@ public sealed class PatientTagResolver : ITagValueResolver
this.chartVitalSource = chartVitalSource;
this.signatureStoreSource = signatureStoreSource;
this.installPath = installPath;
+ this.shareMap = shareMap;
}
#endregion
@@ -1099,7 +1104,7 @@ public sealed class PatientTagResolver : ITagValueResolver
}
// ② 그 경로의 파일을 이 단말에서 찾는다(레거시 캐시 → 경로 자체)
- var found = SignatureImagePath.Resolve(remote, installPath);
+ var found = SignatureImagePath.Resolve(remote, installPath, null, shareMap);
return found.Origin switch
{
SignatureImageOrigin.None => new TagValue(false,
diff --git a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
index 5c59da8..92542c9 100644
--- a/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
+++ b/src/SheetMe.Designer/Views/PreviewWindow.xaml.cs
@@ -234,7 +234,8 @@ public partial class PreviewWindow : Window
PatientIdentity.PathologyDoctorSource(), PatientIdentity.LabDoctorSource(),
PatientIdentity.ExtraStoreSource(), PatientIdentity.ContextStoreSource(),
PatientIdentity.BstSourceFor(picked), PatientIdentity.ChartVitalSourceFor(picked),
- PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath());
+ PatientIdentity.SignatureStoreSource(), PatientIdentity.InstallPath(),
+ PatientIdentity.ShareMap());
// 러너를 새로 만든다. 같은 러너를 재사용하면 옛 환자의 조회 결과가 캐시에 남아
// 환자를 바꿨는데 표는 그대로가 된다 — 태그만 바뀌고 표는 안 바뀌면 아무도 눈치채지 못한다.
fields = new MDataTableRunner(designer.Document,
diff --git a/src/SheetMe.Designer/appsettings.json b/src/SheetMe.Designer/appsettings.json
index 1a5d925..3ee9a2d 100644
--- a/src/SheetMe.Designer/appsettings.json
+++ b/src/SheetMe.Designer/appsettings.json
@@ -11,5 +11,10 @@
"Designer": {
"GridSize": 4,
"SnapThreshold": 6
+ },
+ "Images": {
+ "//": "싸인·직인 이미지를 파일서버 공유(UNC)에서 직접 읽기 위한 경로 대응. DB 는 서버측 절대경로(예: D:\\MsystechHIS\\002\\...)를 들고 있고, 레거시는 그 경로를 ActiveX 전송 데몬으로 받아 온다 — 우리는 같은 파일을 공유로 읽는다. 병원마다 접두가 다르므로 설치 시 채운다. 진단 --db-filecfg 가 현재 상태와 필요한 설정을 알려 준다.",
+ "//예시": "\"SignatureShareMap\": [ \"D:\\\\MsystechHIS=\\\\\\\\192.168.100.222\\\\MsystechHIS\" ] 또는 한 줄로 \"D:\\\\MsystechHIS=\\\\\\\\서버\\\\공유;E:\\\\msys=\\\\\\\\서버\\\\msys\"",
+ "SignatureShareMap": []
}
}
diff --git a/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs b/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs
index ee17e54..4600414 100644
--- a/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs
+++ b/tests/SheetMe.Core.Tests/SignatureImagePathTests.cs
@@ -66,6 +66,51 @@ public sealed class SignatureImagePathTests
Assert.AreEqual(SignatureImageOrigin.DirectPath, result.Origin);
}
+ [TestMethod]
+ public void 공유_매핑이_서버경로를_UNC_로_바꾼다()
+ {
+ var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
+ Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
+ SignatureImagePath.MapToShare(@"D:\MsystechHIS\002\a.tif", map));
+ // 대소문자 무시 — 병원 데이터의 표기가 제각각이다
+ Assert.AreEqual(@"\\fs\MsystechHIS\002\a.tif",
+ SignatureImagePath.MapToShare(@"d:\msystechhis\002\a.tif", map));
+ // 대응 없는 접두는 빈 문자열(엉뚱한 경로를 만들지 않는다)
+ Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"E:\other\a.tif", map));
+ }
+
+ [TestMethod]
+ public void 접두가_겹치면_긴_쪽이_이긴다()
+ {
+ // 짧은 접두가 먼저 걸리면 더 정확한 대응이 묻힌다
+ var map = new[] { @"D:\HIS=\\fs\all", @"D:\HIS\Sign=\\fs\sign" };
+ Assert.AreEqual(@"\\fs\sign\a.tif", SignatureImagePath.MapToShare(@"D:\HIS\Sign\a.tif", map));
+ Assert.AreEqual(@"\\fs\all\Etc\b.tif", SignatureImagePath.MapToShare(@"D:\HIS\Etc\b.tif", map));
+ }
+
+ [TestMethod]
+ public void 설정_오타는_값으로_바꾸지_않는다()
+ {
+ // '=' 가 없거나 한쪽이 비면 그 항목은 무시한다 — 반쪽 경로를 만들면 조용히 틀린다
+ var map = new[] { "그냥문자열", @"D:\HIS=", @"=\\fs\x" };
+ Assert.AreEqual(string.Empty, SignatureImagePath.MapToShare(@"D:\HIS\a.tif", map));
+ }
+
+ [TestMethod]
+ public void 캐시가_없으면_공유를_먼저_보고_직접경로는_그_다음이다()
+ {
+ var map = new[] { @"D:\MsystechHIS=\\fs\MsystechHIS" };
+ var remote = @"D:\MsystechHIS\002\a.tif";
+ var shared = @"\\fs\MsystechHIS\002\a.tif";
+ // 공유에 있으면 공유
+ var viaShare = SignatureImagePath.Resolve(remote, Install, p => p == shared, map);
+ Assert.AreEqual(SignatureImageOrigin.Share, viaShare.Origin);
+ Assert.AreEqual(shared, viaShare.LocalPath);
+ // 공유에 없고 원격 경로가 열리면 직접
+ var viaDirect = SignatureImagePath.Resolve(remote, Install, p => p == remote, map);
+ Assert.AreEqual(SignatureImageOrigin.DirectPath, viaDirect.Origin);
+ }
+
[TestMethod]
public void UNC_공유_경로도_파일명을_뗀다()
{