싸인·직인·로고 17종 — 경로 조회와 이미지 렌더 이식(전송 계층은 재현 불가)

워크플로 조사(읽기 5 + 반박 검증 5)로 전송 계층과 태그 17종을 훑었고,
검증이 조사서의 "FTP" 단정을 반박해 실체가 드러났다: 전송 수단은 단일
프로토콜이 아니라 DB 설정(M_DtlMst 'EMR_FileTransfer' Type)이 고르는
5갈래이고, 새 진단 --db-filecfg 로 확인한 우리 사이트 값은 Type=1 =
FileServer = ActiveX OCX(AxBITSFTLib.AxbitSFT)다. OCX 내부 프로토콜은
코드에 없고 서드파티·ActiveX 는 배포 금지라 <b>다운로드는 재현할 수 없다</b>.

그래서 받지 않고 찾는다: ①레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
({설치경로}\Tmp\DownLoadedFiles\Signature\ — 레거시가 쓰는 그 폴더),
②DB 경로 자체가 열리는 경우(이 병원 저장 형태는 드라이브 절대경로).
둘 다 없으면 사유 → 종이는 빈칸(어제 결정). 판정은 순수 함수
SignatureImagePath 로 두고 테스트 7건(캐시 우선·직접 경로·둘 다 없음·
빈 경로·설치경로 없음·UNC).

경로 조회는 전부 순수 SQL 이라 그대로 이식(SignatureStore): 집도의
(DTRUIDCOD)·마취(수술 ANEUIDCOD / 부서 — 개원일 2021-03-01 분기)·
영상의학(XRAY)·진단검사(LAB)·외출외박 승인자(병동/원무)·병동 수간호사·
병원로고(M_HspMst.HspLgoPth)·병원직인(주석과 달리 DB_REGISTRY 의
'직인경로'). 로그인 사용자 3종은 세션 행에 UidImgPth 를 얹어 왕복 0,
담당의·진단서 의사는 프리페치 행 재사용.

레거시 결함 보존 2건: ETC_병리판독의사싸인은 SELECT 에 UidNam 만 있고
UidImgPth 를 읽어 항상 예외→빈 값(:17434), OCM_Cosign_싸인은 저장 기록
키(EmrKey) 의존이라 미리보기에서 값이 될 수 없다 — 둘 다 사유로 완결.

렌더: PictureBox 가 자리표시자만 그리던 것을 실제 이미지로. 파일을
잠그지 않고(OnLoad) 읽으며, 인쇄가 SizeMode 를 무시하고 사각형에 늘려
그리는 레거시 동작에 맞춰 기본 Stretch=Fill(Zoom 만 비율 유지).
WithTagValue 는 이미지류에서 Text 대신 경로 속성을 채운다.
분류도 정정 — 싸인 계열이 Image(글자만 보여 준다)에서 PatientContext 로.

- dotnet test 354/354 (경로 판정 7건 추가) · --edit-smoke 실패 0
- --db-patient ①~㊲ 전건 통과 — ㊲ 신설: 9개 경로 조회 실행,
  마취과 의사 싸인 경로가 실값 29자로 나온다
- --db-filecfg 신설(값 없이 모드만): Type=1 · E_FilInf 102만행 ·
  UidImgPth 157행 · 경로 모양 드라이브 절대경로
- --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 불변

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-20 10:18:35 +09:00
co-authored by Claude Fable 5
parent 2310971a10
commit a0333791b6
16 changed files with 781 additions and 16 deletions
@@ -204,6 +204,16 @@ public static class PatientIdentity
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>문맥 저장소 공급자 — 입원과·퇴원과의 시점별 진료과 조회용</summary>
public static Func<PatientContextStore?> ContextStoreSource()
{
@@ -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()));
}
@@ -228,6 +228,12 @@ public sealed class PatientTagResolver : ITagValueResolver
private readonly Dictionary<string, IReadOnlyDictionary<string, string>> specialDoctorCache = new(StringComparer.Ordinal);
/// <summary>싸인·이미지 경로 조회 공급자 — 태그가 물을 때 만든다</summary>
private readonly Func<SignatureStore?>? signatureStoreSource;
/// <summary>HIS 설치 경로 — 레거시 싸인 캐시 폴더의 뿌리(ServerInfo ini)</summary>
private readonly string? installPath;
/// <summary>환자 부가정보(자기 SQL 7종) 공급자 — 태그가 물을 때 만든다</summary>
private readonly Func<PatientExtraStore?>? extraStoreSource;
@@ -265,7 +271,9 @@ public sealed class PatientTagResolver : ITagValueResolver
Func<PatientExtraStore?>? extraStoreSource = null,
Func<PatientContextStore?>? contextStoreSource = null,
Func<string, string>? bstSource = null,
Func<string, string, string[], string, string>? chartVitalSource = null)
Func<string, string, string[], string, string>? chartVitalSource = null,
Func<SignatureStore?>? 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, "수술일자 값이 형식에 맞지 않습니다");
}
/// <summary><see cref="FromSignature"/> 가 담당하는 이미지 태그 17종</summary>
private static readonly HashSet<string> SignatureTags = new(StringComparer.Ordinal)
{
"OCM_담당의사싸인", "OCM_진단서의사싸인", "OCM_로그인_사용자싸인",
"ETC_로그인사용자_서명", "ECT_로그인사용자싸인",
"OCM_수술집도의_싸인", "OCM_수술마취의사싸인", "OCM_수술마취의사싸인_수술접수연동",
"OCM_Cosign_싸인", "ETC_병리판독의사싸인", "ETC_영상의학과의사싸인", "ETC_진단검사의사싸인",
"ETC_요양기관명칭_병원로고", "ETC_요양기관명칭_병원직인",
"ETC_외출외박_확인간호사싸인", "ETC_외출외박_확인원무과싸인",
"ETC_로그인사용자_병동_수간호사_싸인",
};
/// <summary>
/// 싸인·직인·로고 17종 — <b>DB 는 경로만 준다</b>. 레거시는 그 경로를 파일서버에서
/// 내려받아 로컬 경로를 돌려주지만, 이 병원 설정의 전송 계층은 ActiveX OCX
/// (M_DtlMst 'EMR_FileTransfer' Type=1 — 진단 <c>--db-filecfg</c> 실측)라
/// 프로토콜이 코드에 없어 .NET 으로 재현할 수 없다(서드파티·ActiveX 배포 금지이기도 하다).
///
/// 그래서 <b>받지 않고 찾는다</b>: 레거시 EMR 이 같은 단말에 이미 받아 둔 캐시
/// 폴더를 먼저 보고, 없으면 DB 경로 자체가 열리는지 본다
/// (<see cref="SignatureImagePath"/>). 둘 다 없으면 사유 — 종이에는 빈칸이다.
///
/// 값(경로)이 나오면 <b>PictureBox 가 그 파일을 그린다</b>(PrintService.WithTagValue).
/// </summary>
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),
};
}
}
/// <summary>태그별 원격 경로 — 조회가 연결되지 않았으면 null</summary>
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,
};
}
/// <summary>
/// 나이 계열 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_퇴원약_횟수",
@@ -218,6 +218,19 @@ public static class PrintService
{
return control;
}
// 이미지류(싸인·직인·로고)의 값은 <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)
{