'수정'을 되살린다 — 지금 쓰이는 버전을 제자리에서 고친다

레거시 TK_MODIFY('기록지 수정'). SheetMe 에는 저장만 있었고 이것이 없었다.
DbSmoke.cs:1072 가 그 부재를 이미 적어 뒀다 — 실측 ShtCneYon='Y' 40건, 현역 19건.

<b>먼저 내 앞선 설명을 고친다.</b> 나는 수정이 ShtCneYon 제자리 갱신 경로라고 했는데 틀렸다.
수정 경로는 ShtCneYon 을 <b>읽지도 않는다</b>. 그 컬럼은 저장이 버저닝이냐 제자리냐만 가른다.

저장과 수정은 이력 방향이 반대다.
  저장: 옛 행을 SdgDelYon='Y' 로 내리고 <b>새 행이 활성</b>이 된다(SdgKey 바뀜)
  수정: <b>활성 행을 제자리에서 갱신</b>하고 옛 XML 을 담은 새 행이 이력이 된다(SdgKey 유지)
SdgKey 가 유지되므로 EMR 이 참조하던 키가 그대로다 — 그게 이 동작의 존재 이유다.

E_SctMst 를 건드리지 않는다(레거시 :170-174 주석 처리). 그것이 "글자만 수정 가능"이라는
경고문의 기술적 근거다 — 기존 매핑 행은 SdgKey 가 그대로라 유효하지만 새 컨트롤은 행이 안 생긴다.

<b>레거시 버그 둘은 복제하지 않는다.</b>
① 감사 컬럼이 뒤집혀 있었다 — 활성 행에는 아무것도 안 쓰고 <b>옛 디자인을 담은 이력 행</b>이
   현재 사용자·시각을 받았다(:162-163). 이력 패널이 "누가 언제 이 버전을 만들었는가"를 거꾸로 보여 준다.
   활성 행에 기록하고 이력 행은 원래 값을 지킨다.
② Rows(0) 을 개수 검사보다 먼저 읽어 활성 행이 없으면 IndexOutOfRange 였다(:150).
   먼저 확인하고 "수정 대신 저장을 쓰세요"로 안내한다.

<b>새 컨트롤은 막는다.</b> 레거시는 산문으로만 경고하고 막지 않았다 —
경고문을 읽지 않으면 그대로 번진다. SctMstXmlWalker 로 문서의 컨트롤 이름을 뽑아
활성 SdgKey 의 E_SctMst 행과 대조한다. 판정 근거를 저장이 행을 만드는 규칙과 같은 것으로 통일해서,
"행이 원래 안 생기는 종류"를 새 컨트롤로 오인하지 않는다.

<b>권한 게이트는 유지한다.</b> 레거시가 이 버튼만 전산실에 묶어 둔 이유가 위와 같다
(frmSheetDesigner.vb:1029-1060, 주석 '전산실만 사용가능'). 저장 버튼에는 그런 분기가 없다.
규칙을 Core 의 순수 함수로 옮겨 표로 고정했다 — 병원·부서 조합이 세 갈래라
화면에서 즉석 판정하면 어느 갈래가 왜 막혔는지 확인할 방법이 없다.
막힌 이유도 갈래마다 다르게 말한다("권한 없음"만으로는 누구에게 물어야 할지 모른다).

<b>HspStrDte 는 세션에 없다.</b> 레거시는 그 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
빈 문자열로 두면 그 갈래가 안 걸리고 부서 규칙(EDPS)으로 떨어진다 — 레거시의 다수 경로와 같다.
없는 값을 지어내 더 조이지 않고, 이 사실을 코드와 테스트에 적어 뒀다.

게이트: 테스트 298/298(신규 7), --edit-smoke 0실패, --dialog-shots FAIL 0 대조군 4/4,
--modal-check 0실패, --cleartype 11/11, --maxrect 0실패, --scale-budget 5/5, 빌드 경고 0,
--db-smoke 1271건 diff 0, --db-render P062 md5 8d683835f5d81e7bb41c79071d6bf954 동일.

<b>실DB 수정 경로는 아직 안 돌려 봤다</b> — 운영 테이블에 쓰는 동작이라 --db-* 진단으로
왕복 검증을 붙이는 것이 다음 일이다.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-17 21:17:55 +09:00
co-authored by Claude Opus 5
parent a386c5504b
commit ae2f8f2f61
6 changed files with 412 additions and 2 deletions
+66
View File
@@ -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
}
@@ -286,6 +286,134 @@ public sealed class OracleLegacyFormStore
/// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션. /// 디자인 저장 — 레거시 동일 의미론(제자리 갱신 vs 버저닝), 단일 트랜잭션.
/// 반환: 저장된 SdgKey. /// 반환: 저장된 SdgKey.
/// </summary> /// </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) public decimal SaveDesign(FormDocument document, string uid)
{ {
if (string.IsNullOrWhiteSpace(document.FormId)) if (string.IsNullOrWhiteSpace(document.FormId))
@@ -1,4 +1,5 @@
using SheetMe.Core.Models; using SheetMe.Core.Catalog;
using SheetMe.Core.Models;
using SheetMe.Data.Config; using SheetMe.Data.Config;
using SheetMe.Data.Stores; using SheetMe.Data.Stores;
using SheetMe.Designer.Services; using SheetMe.Designer.Services;
@@ -105,6 +106,38 @@ internal sealed class FormDesignDataBusiness
return DbStore().SaveDesign(document, AuditUid); 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>
/// HspStrDte 는 세션에 없다 — 레거시는 이 값으로 "2025-05-01 이후 개원 병원은 사내 계정만"을 가른다.
/// 모르는 값을 빈 문자열로 두면 그 갈래가 <b>안 걸리고</b> 부서 규칙(EDPS)으로 떨어진다.
/// 없는 값을 지어내 더 조이는 것보다, 레거시의 다수 경로와 같게 두고 이 사실을 적어 두는 쪽을 택한다.
/// </summary>
private const string HospitalStartDate = "";
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary> /// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod); public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
@@ -179,6 +179,14 @@ internal sealed class MainViewModel : ViewModelBase
/// </summary> /// </summary>
public bool CanSaveToDb => dataBusiness.CanSaveToDb; 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> /// <summary>좌측 패널 탭(0=서식 목록, 1=레이어, 2=도구 상자) — 서식 열면 레이어로 자동 전환([200] 관행)</summary>
public int SelectedLeftTabIndex public int SelectedLeftTabIndex
{ {
@@ -215,6 +223,7 @@ internal sealed class MainViewModel : ViewModelBase
}); });
SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true)); SaveAsFileCommand = new Command((sender, e) => OnSaveFile(saveAs: true));
SaveToDbCommand = new Command((sender, e) => OnSaveToDb()); SaveToDbCommand = new Command((sender, e) => OnSaveToDb());
ModifyInDbCommand = new Command((sender, e) => OnModifyInDb());
ExportJsonCommand = new Command((sender, e) => OnExportJson()); ExportJsonCommand = new Command((sender, e) => OnExportJson());
ImportJsonCommand = new Command((sender, e) => OnImportJson()); ImportJsonCommand = new Command((sender, e) => OnImportJson());
PreviewCommand = new Command((sender, e) => OnPreview()); PreviewCommand = new Command((sender, e) => OnPreview());
@@ -591,6 +600,87 @@ internal sealed class MainViewModel : ViewModelBase
} }
} }
/// <summary>
/// 제자리 정정 — 레거시 '기록지 수정'.
///
/// 저장과 무엇이 다른가: 저장은 새 버전을 만들어 그것이 활성이 되고 옛 것이 이력이 된다.
/// 수정은 <b>지금 쓰이는 버전을 그 자리에서 고친다</b> — SdgKey 가 안 바뀌므로
/// EMR 이 참조하던 키가 유지되고, 옛 내용만 이력으로 떨어진다.
///
/// 그래서 새 컨트롤을 <b>막는다</b>. 수정은 E_SctMst 를 안 건드리므로 새 컨트롤은 매핑 행 없이
/// 저장되고 EMR 에서 조용히 값이 안 채워진다. 레거시는 이것을 산문으로만 경고했고
/// ("새로운 컨트롤이 추가될 경우 수정이 아닌 저장을 이용하시기 바랍니다") 막지는 않았다.
/// 경고문을 읽지 않으면 그대로 번지므로 여기서는 이름을 대조해 막는다.
/// </summary>
private bool OnModifyInDb()
{
if (CurrentDesigner is not { } designer)
{
return false;
}
try
{
if (!EnsureDb() || !dataBusiness.CanWriteDb)
{
DialogService.Notify(DialogKind.Warning, "수정",
"HIS 사용자가 확인되지 않아 수정할 수 없습니다.",
"이력에 남길 수정자를 특정할 수 없습니다.");
return false;
}
// 저장 경로와 같은 게이트 — 열기 이후 마스터에서 플래그가 꺼졌을 수 있다
if (!EnsureDesignAllowed(designer.Document.FormId))
{
return false;
}
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, "오류",
"수정 중 오류가 발생했습니다. (트랜잭션 롤백됨)", ex.Message);
return false;
}
}
// 'DB에서 열기' 대화상자(680×560)를 지웠다. // 'DB에서 열기' 대화상자(680×560)를 지웠다.
// //
// 좌측 '서식 목록' 탭이 <b>같은 조회</b>를 상시로 한다(둘 다 ListSheets, WHERE·ORDER BY·상한 동일). // 좌측 '서식 목록' 탭이 <b>같은 조회</b>를 상시로 한다(둘 다 ListSheets, WHERE·ORDER BY·상한 동일).
+12 -1
View File
@@ -168,7 +168,18 @@
<MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/> <MenuItem Header="서식 목록에서 열기(_O)" Click="OnOpenFromListClick" InputGestureText="Ctrl+O"/>
<Separator/> <Separator/>
<MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S" <MenuItem Header="저장(_S)" Command="{Binding SaveToDbCommand}" InputGestureText="Ctrl+S"
IsEnabled="{Binding CanSaveToDb}"/> IsEnabled="{Binding CanSaveToDb}"
ToolTip="새 버전을 만듭니다. 지금 버전은 이력으로 남습니다"/>
<!--
수정 — 레거시 '기록지 수정'. 저장과 다른 동작이라 따로 둔다.
저장은 새 버전을 만들어 그것이 활성이 되고, 수정은 지금 쓰이는 버전을
그 자리에서 고친다(SdgKey 유지). E_SctMst 를 안 건드리므로 글자만 고칠 수 있고,
새 컨트롤이 있으면 커맨드가 막는다.
레거시처럼 전산실에만 열어 둔다 — 저장 버튼에는 그런 분기가 없다.
-->
<MenuItem Header="수정(_M) — 지금 버전을 제자리에서" Command="{Binding ModifyInDbCommand}"
IsEnabled="{Binding CanModifyInDb}"
ToolTip="새 버전을 만들지 않고 지금 버전의 글자만 고칩니다 (전산실 전용)"/>
<Separator/> <Separator/>
<MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/> <MenuItem Header="미리보기(_V)..." Command="{Binding PreviewCommand}"/>
<MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/> <MenuItem Header="인쇄(_P)..." Command="{Binding PrintCommand}" InputGestureText="Ctrl+P"/>
@@ -0,0 +1,82 @@
using SheetMe.Core.Catalog;
namespace SheetMe.Core.Tests;
/// <summary>
/// '수정' 권한 규칙 고정 — 레거시 <c>TK_MODIFY</c> 가시성 분기를 옮긴 것.
///
/// 이 표가 느슨해지면 전산실이 아닌 사람이 <b>지금 쓰이는 버전을 제자리에서</b> 고치게 된다.
/// 수정은 새 버전을 만들지 않으므로 되돌릴 표식이 이력 한 행뿐이고,
/// <c>E_SctMst</c> 를 건드리지 않아 새 컨트롤은 조용히 배선에서 빠진다.
/// 반대로 너무 조이면 지금 이 일을 하고 있는 전산실이 못 쓴다.
/// </summary>
[TestClass]
public sealed class ModifyPolicyTests
{
#region Methods
[TestMethod]
public void _규칙은_전산실만()
{
Assert.IsTrue(ModifyPolicy.CanModify("ABC", "EDPS", "011825", string.Empty));
Assert.IsFalse(ModifyPolicy.CanModify("ABC", "NURS", "011825", string.Empty));
Assert.IsFalse(ModifyPolicy.CanModify("ABC", string.Empty, "011825", string.Empty));
}
[TestMethod]
public void _병원은_총무_구매도_쓴다()
{
// 레거시 frmSheetDesigner.vb:1033-1038
foreach (var dep in new[] { "EDPS", "SALO", "SAVE" })
{
Assert.IsTrue(ModifyPolicy.CanModify("HANKOOK", dep, "011825", string.Empty), dep);
Assert.IsTrue(ModifyPolicy.CanModify("GNRH", dep, "011825", string.Empty), dep);
}
Assert.IsFalse(ModifyPolicy.CanModify("HANKOOK", "NURS", "011825", string.Empty));
}
[TestMethod]
public void _병원_규칙이_전산실_규칙보다_먼저다()
{
// HANKOOK 에서 SALO 는 전산실이 아니지만 허용된다 — 순서가 뒤집히면 이 사람이 막힌다.
Assert.IsTrue(ModifyPolicy.CanModify("HANKOOK", "SALO", "누구든", string.Empty));
}
[TestMethod]
public void _개원_병원은_사내_계정만()
{
// 레거시 :1042-1048 — HspStrDte >= 20250501
Assert.IsTrue(ModifyPolicy.CanModify("XYZ", "NURS", "MSYS", "20250501"));
Assert.IsTrue(ModifyPolicy.CanModify("XYZ", "NURS", "MSYS", "20260101"));
// 그 병원에서는 전산실이어도 안 된다
Assert.IsFalse(ModifyPolicy.CanModify("XYZ", "EDPS", "011825", "20250501"));
// 경계 하루 전은 부서 규칙으로 떨어진다
Assert.IsTrue(ModifyPolicy.CanModify("XYZ", "EDPS", "011825", "20250430"));
Assert.IsFalse(ModifyPolicy.CanModify("XYZ", "NURS", "MSYS", "20250430"));
}
[TestMethod]
public void _모르면_부서_규칙으로_떨어진다()
{
// 세션에 HspStrDte 가 없다. 빈 문자열은 20250501 보다 작으므로 그 갈래가 안 걸린다 —
// 레거시의 다수 경로와 같은 판정이다. 이 동작이 바뀌면 전산실이 통째로 막힌다.
Assert.IsTrue(ModifyPolicy.CanModify("XYZ", "EDPS", "011825", string.Empty));
Assert.IsFalse(ModifyPolicy.CanModify("XYZ", "NURS", "011825", string.Empty));
}
[TestMethod]
public void _이유가_갈래마다_다르다()
{
// "권한이 없습니다"만 말하면 누구에게 물어야 할지 모른다.
StringAssert.Contains(ModifyPolicy.DenyReason("HANKOOK", "011825", string.Empty), "총무");
StringAssert.Contains(ModifyPolicy.DenyReason("XYZ", "011825", "20250501"), "MSYS");
StringAssert.Contains(ModifyPolicy.DenyReason("XYZ", "011825", string.Empty), "전산실");
}
[TestMethod]
public void _가리지_않는다()
{
Assert.IsTrue(ModifyPolicy.CanModify("hankook", "edps", "x", string.Empty));
Assert.IsTrue(ModifyPolicy.CanModify("XYZ", "NURS", "msys", "20250501"));
}
#endregion
}