diff --git a/src/SheetMe.Core/Catalog/PatientPreviewPolicy.cs b/src/SheetMe.Core/Catalog/PatientPreviewPolicy.cs
new file mode 100644
index 0000000..2ed1892
--- /dev/null
+++ b/src/SheetMe.Core/Catalog/PatientPreviewPolicy.cs
@@ -0,0 +1,49 @@
+namespace SheetMe.Core.Catalog;
+
+///
+/// 환자 데이터로 미리보기를 볼 수 있는 조건.
+///
+/// 레거시에서 옮기지 않는 것이 요점이다. 레거시 서식생성기는
+/// · 폼이 뜰 때마다 인가코드를 InputBox 로 묻고 통과값 3개가 소스에 평문으로 박혀 있고
+/// (frmSheetDesigner.vb:913),
+/// · 환자를 검색조건 없이 운영 전체에서 찾을 수 있고(:636),
+/// · 누가 어느 환자를 열었는지 남기지 않는다(감사 0건).
+/// 그 셋을 그대로 옮기면 신규 앱이 최악을 물려받는다.
+///
+/// 여기서 정하는 것은 누가 볼 수 있는가뿐이다. 검색 범위는 레거시와 같게 두기로 했고
+/// (전체 검색 + 감사 로그), 그래서 남기는 쪽이 안전장치의 본체다 —
+/// 조회 자체를 막지 않는 대신 흔적을 남긴다.
+///
+/// 조건은 와 같은 축을 쓴다. 환자 기록을 보는 일은
+/// 지금 쓰이는 서식을 제자리에서 고치는 일과 무게가 다르지 않다.
+///
+public static class PatientPreviewPolicy
+{
+ #region Methods
+ ///
+ /// 이 사용자가 환자 데이터로 미리보기를 볼 수 있는가.
+ ///
+ /// 인증된 HIS 사용자여야 한다 — 미인증이면 감사 로그에 남길 주체가 없고,
+ /// 주체 없는 환자 조회는 그 자체로 남기면 안 되는 일이다.
+ ///
+ public static bool CanPreviewPatient(string hospitalCode, string departmentCode, string uidCod,
+ string hospitalStartDate)
+ => uidCod.Length > 0
+ && ModifyPolicy.CanModify(hospitalCode, departmentCode, uidCod, hospitalStartDate);
+
+ /// 왜 막혔는지 — "권한이 없습니다"만으로는 누구에게 물어야 할지 모른다
+ public static string DenyReason(string hospitalCode, string uidCod, string hospitalStartDate)
+ => uidCod.Length == 0
+ ? "HIS 사용자가 확인되지 않았습니다. 기록지정보 화면에서 실행하거나 사용자 코드를 전달해 주세요."
+ : ModifyPolicy.DenyReason(hospitalCode, uidCod, hospitalStartDate);
+
+ ///
+ /// 감사 로그 한 줄 — 누가·언제·어느 내원을 열었는가.
+ ///
+ /// 문구를 여기서 만드는 이유: 호출부마다 다르게 적으면 나중에 로그를 찾을 수 없다.
+ /// 환자 이름은 넣지 않는다 — 내원번호로 되짚을 수 있고, 로그 파일에 이름을 흘릴 이유가 없다.
+ ///
+ public static string AuditLine(string uidCod, string comNum)
+ => $"[환자 미리보기] 사용자={uidCod} 내원={comNum}";
+ #endregion
+}
diff --git a/tests/SheetMe.Core.Tests/PatientPreviewPolicyTests.cs b/tests/SheetMe.Core.Tests/PatientPreviewPolicyTests.cs
new file mode 100644
index 0000000..d58d224
--- /dev/null
+++ b/tests/SheetMe.Core.Tests/PatientPreviewPolicyTests.cs
@@ -0,0 +1,54 @@
+using SheetMe.Core.Catalog;
+
+namespace SheetMe.Core.Tests;
+
+///
+/// 환자 미리보기 접근 규칙 고정.
+///
+/// 이 표가 느슨해지면 서식을 만드는 사람 누구나 운영 환자 기록을 열 수 있다.
+/// 레거시가 그 상태였다 — 인가코드가 소스에 평문이고, 검색 범위 무제한이고, 감사 0건이다.
+/// 검색 범위는 레거시와 같게 두기로 했으므로 감사와 권한이 유일한 장치다.
+///
+[TestClass]
+public sealed class PatientPreviewPolicyTests
+{
+ #region Methods
+ [TestMethod]
+ public void 미인증이면_볼_수_없다()
+ {
+ // 주체가 없으면 감사 로그에 남길 것이 없다 — 남길 수 없는 조회는 하지 않는다.
+ Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("ABC", "EDPS", string.Empty, string.Empty));
+ StringAssert.Contains(
+ PatientPreviewPolicy.DenyReason("ABC", string.Empty, string.Empty), "HIS 사용자");
+ }
+
+ [TestMethod]
+ public void 수정_권한과_같은_축을_쓴다()
+ {
+ // 환자 기록을 보는 일은 지금 쓰이는 서식을 제자리에서 고치는 일과 무게가 다르지 않다.
+ Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("ABC", "EDPS", "011825", string.Empty));
+ Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("ABC", "NURS", "011825", string.Empty));
+ Assert.IsTrue(PatientPreviewPolicy.CanPreviewPatient("HANKOOK", "SALO", "011825", string.Empty));
+ Assert.IsFalse(PatientPreviewPolicy.CanPreviewPatient("XYZ", "EDPS", "011825", "20250501"));
+ }
+
+ [TestMethod]
+ public void 감사_한_줄에_주체와_내원이_들어간다()
+ {
+ var line = PatientPreviewPolicy.AuditLine("011825", "C12345");
+ StringAssert.Contains(line, "011825");
+ StringAssert.Contains(line, "C12345");
+ StringAssert.Contains(line, "환자 미리보기");
+ }
+
+ [TestMethod]
+ public void 감사_한_줄에_환자_이름은_넣지_않는다()
+ {
+ // 내원번호로 되짚을 수 있다. 로그 파일에 이름을 흘릴 이유가 없다 —
+ // 로그는 오래 남고 접근 통제가 DB 보다 느슨하다.
+ var line = PatientPreviewPolicy.AuditLine("011825", "C12345");
+ Assert.IsFalse(line.Contains("이름", StringComparison.Ordinal));
+ Assert.AreEqual(2, line.Split('=').Length - 1, $"항목이 둘(사용자·내원)이어야 한다: {line}");
+ }
+ #endregion
+}