diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs
index d7b7d40..78c9343 100644
--- a/src/SheetMe.Data/Config/DataConfig.cs
+++ b/src/SheetMe.Data/Config/DataConfig.cs
@@ -18,6 +18,13 @@ public sealed class DataConfig
/// 파일 모드 서식 폴더
public string XmlFolder { get; set; } = "forms";
+
+ ///
+ /// 개발용 HIS 사용자 코드 — 기동 인자 없이 실행할 때만 시도하는 폴백.
+ /// 운영 배포본의 appsettings.json 에는 이 키가 없으므로 자동으로 비활성이다.
+ /// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
+ ///
+ public string DevUidCod { get; set; } = string.Empty;
#endregion
#region Methods
diff --git a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs
index c95adb7..3024152 100644
--- a/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs
+++ b/src/SheetMe.Data/Stores/OracleLegacyFormStore.cs
@@ -5,7 +5,21 @@ using SheetMe.Core.Serialization;
namespace SheetMe.Data.Stores;
/// 서식 목록 1건(열기 대화상자)
-public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign);
+/// 서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용
+public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed);
+
+/// 서식생성기 사용 권한 판정 결과
+public enum SheetDesignPermission
+{
+ /// E_ShtMst 에 서식 코드가 없음
+ NotRegistered,
+
+ /// 허용 — ShtUsrDesYon 이 'Y' 또는 미설정
+ Allowed,
+
+ /// 거부 — ShtUsrDesYon 이 'N' 등
+ Denied,
+}
/// 디자인 버전 1건
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
@@ -54,7 +68,8 @@ public sealed class OracleLegacyFormStore
command.CommandText =
"SELECT * FROM (" +
" SELECT s.ShtCod, s.ShtKorNam," +
- " (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount" +
+ " (SELECT COUNT(*) FROM E_SdgMst d WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') AS DesignCount," +
+ " NVL(TRIM(s.ShtUsrDesYon),' ') AS UsrDesYon" +
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
$") WHERE ROWNUM <= {max}";
@@ -65,11 +80,36 @@ public sealed class OracleLegacyFormStore
result.Add(new SheetSummary(
reader.GetString(0),
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
- reader.GetDecimal(2) > 0));
+ reader.GetDecimal(2) > 0,
+ IsDesignAllowed(reader.GetString(3))));
}
return result;
}
+ ///
+ /// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 frmSheetDesigner.vb:71-84 규칙.
+ /// 'Y' 또는 빈 문자열/NULL(미설정 = 레거시 데이터 호환)이면 허용, 그 외('N' 등)만 거부한다.
+ ///
+ public SheetDesignPermission GetDesignPermission(string shtCod)
+ {
+ using var connection = Open();
+ var value = Scalar(connection, null,
+ "SELECT NVL(TRIM(ShtUsrDesYon),' ') FROM E_ShtMst WHERE ShtCod = :s",
+ new OracleParameter("s", shtCod)) as string;
+ if (value is null)
+ {
+ return SheetDesignPermission.NotRegistered;
+ }
+ return IsDesignAllowed(value) ? SheetDesignPermission.Allowed : SheetDesignPermission.Denied;
+ }
+
+ /// ShtUsrDesYon 판정 — CHAR 패딩을 흡수하려 TRIM 후 비교(레거시보다 관대한 방향이라 오탐만 줄인다)
+ private static bool IsDesignAllowed(string flag)
+ {
+ var trimmed = flag.Trim();
+ return trimmed is "Y" or "";
+ }
+
/// 활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움
public FormDocument? LoadActiveDesign(string shtCod)
{
@@ -214,12 +254,15 @@ public sealed class OracleLegacyFormStore
}
var mergedXml = serializer.Write(document);
- var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리)
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
{
+ // 레거시 SystemDateTime 과 동일하게 DB 서버 시각을 쓴다 — 단말 시계가 밀리면
+ // ResolveActiveSdgKey 의 ORDER BY SdgUpdDtm 이 활성 버전을 잘못 고를 수 있다.
+ var now = ServerClock.Read(connection, transaction).Minute12;
+
var shtCneYon = Scalar(connection, transaction,
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
new OracleParameter("s", document.FormId)) as string;
@@ -302,17 +345,19 @@ public sealed class OracleLegacyFormStore
using var transaction = connection.BeginTransaction();
try
{
+ var stamp = ServerClock.Read(connection, transaction);
+ // ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다
NonQuery(connection, transaction,
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
new OracleParameter("cod", shtCod),
- new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")),
+ new OracleParameter("adp", stamp.Date8),
new OracleParameter("nam", korName),
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
new OracleParameter("uid", uid),
- new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm")));
+ new OracleParameter("dtm", stamp.Minute12));
transaction.Commit();
}
catch
diff --git a/src/SheetMe.Data/Stores/RecordWordStore.cs b/src/SheetMe.Data/Stores/RecordWordStore.cs
index 34e2fe1..69478ea 100644
--- a/src/SheetMe.Data/Stores/RecordWordStore.cs
+++ b/src/SheetMe.Data/Stores/RecordWordStore.cs
@@ -1,4 +1,4 @@
-using Oracle.ManagedDataAccess.Client;
+using Oracle.ManagedDataAccess.Client;
namespace SheetMe.Data.Stores;
@@ -54,6 +54,8 @@ public sealed class RecordWordStore
using var transaction = connection.BeginTransaction();
try
{
+ // 서버 시각 1회 — RETURNING 경로와 폴백 경로가 같은 값을 쓰게 한다
+ var stamp = ServerClock.Read(connection, transaction).Milli17;
var seq = Convert.ToDecimal(Scalar(connection, transaction,
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
new OracleParameter("s", shtCod))!);
@@ -74,7 +76,7 @@ public sealed class RecordWordStore
command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("q", seq));
command.Parameters.Add(new OracleParameter("u", uid));
- command.Parameters.Add(new OracleParameter("d", Now17()));
+ command.Parameters.Add(new OracleParameter("d", stamp));
var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output);
command.Parameters.Add(outKey);
command.ExecuteNonQuery();
@@ -94,7 +96,7 @@ public sealed class RecordWordStore
new OracleParameter("v", value),
new OracleParameter("q", seq),
new OracleParameter("u", uid),
- new OracleParameter("d", Now17()));
+ new OracleParameter("d", stamp));
}
transaction.Commit();
@@ -111,20 +113,28 @@ public sealed class RecordWordStore
public void Update(decimal key, string value, string uid)
{
using var connection = Open();
+ var stamp = ServerClock.Read(connection, null).Milli17;
using var command = connection.CreateCommand();
command.BindByName = true;
command.CommandText =
"UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k";
command.Parameters.Add(new OracleParameter("v", value));
command.Parameters.Add(new OracleParameter("u", uid));
- command.Parameters.Add(new OracleParameter("d", Now17()));
+ command.Parameters.Add(new OracleParameter("d", stamp));
command.Parameters.Add(new OracleParameter("k", key));
command.ExecuteNonQuery();
}
- /// 상용구 삭제(하위 단계 포함)
- public void Remove(decimal key)
+ ///
+ /// 상용구 삭제(하위 단계 포함) — 물리 DELETE.
+ /// auditUid 를 DB 에 남길 곳은 없다: 레거시 런타임 조회 쿼리(dtRecWordMst.vb)에 ShtStt 필터가 없어
+ /// 논리 삭제로 바꾸면 지운 문구가 EMR 작성 화면에 계속 노출된다. 그럼에도 인자로 받는 이유는
+ /// "모든 쓰기 API 는 uid 를 명시한다"는 규약을 컴파일 타임에 강제해, 나중에 uid 없는 경로가
+ /// 새로 생기지 않게 하기 위해서다.
+ ///
+ public void Remove(decimal key, string auditUid)
{
+ _ = auditUid;
using var connection = Open();
using var transaction = connection.BeginTransaction();
try
@@ -149,13 +159,15 @@ public sealed class RecordWordStore
using var transaction = connection.BeginTransaction();
try
{
+ // 루프 밖에서 1회 — 한 번의 순서 변경이 행마다 다른 시각을 남기지 않게 한다
+ var stamp = ServerClock.Read(connection, transaction).Milli17;
for (var i = 0; i < orderedKeys.Count; i++)
{
NonQuery(connection, transaction,
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
new OracleParameter("q", (decimal)(i + 1)),
new OracleParameter("u", uid),
- new OracleParameter("d", Now17()),
+ new OracleParameter("d", stamp),
new OracleParameter("k", orderedKeys[i]));
}
transaction.Commit();
@@ -174,9 +186,6 @@ public sealed class RecordWordStore
return connection;
}
- /// SHTUPDDTF 17자리(yyyyMMddHHmmssfff)
- private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff");
-
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
{
using var command = connection.CreateCommand();
diff --git a/src/SheetMe.Data/Stores/ServerClock.cs b/src/SheetMe.Data/Stores/ServerClock.cs
new file mode 100644
index 0000000..0d6e249
--- /dev/null
+++ b/src/SheetMe.Data/Stores/ServerClock.cs
@@ -0,0 +1,50 @@
+using System.Globalization;
+using Oracle.ManagedDataAccess.Client;
+
+namespace SheetMe.Data.Stores;
+
+///
+/// 감사 컬럼 시각의 유일한 출처 = DB 서버 시각(레거시 clsCommonLib.SystemDateTime 동형 —
+/// 레거시도 SELECT Sysdate FROM DUAL 로 서버 시각을 받아 클라이언트에서 포맷한다).
+///
+/// 단순한 감사 정확성 문제가 아니다. ResolveActiveSdgKey 가
+/// ORDER BY SdgUpdDtm DESC, SdgKey DESC 로 활성 버전을 고르므로,
+/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다.
+///
+internal static class ServerClock
+{
+ #region Types
+ /// 서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공
+ internal readonly record struct Stamp(DateTime Value)
+ {
+ /// yyyyMMdd (8자리) — ShtAdpDte 류
+ public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
+
+ /// yyyyMMddHHmm (12자리) — SdgUpdDtm/ShtUpdDtm
+ public string Minute12 => Value.ToString("yyyyMMddHHmm", CultureInfo.InvariantCulture);
+
+ /// yyyyMMddHHmmssfff (17자리) — 상용구 ShtUpdDtf/ShtFstDtf
+ public string Milli17 => Value.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
+ }
+ #endregion
+
+ #region Methods
+ ///
+ /// 서버 시각 조회 — 왕복 1회로 3포맷을 확보한다.
+ /// 포맷 마스크를 SQL 에 두지 않고 값만 받아 invariant 로 포맷해 NLS 설정 의존을 없앤다.
+ /// 실패 시 폴백하지 않는다 — 커넥션이 죽은 것이고 뒤따르는 DML 도 어차피 실패한다.
+ ///
+ public static Stamp Read(OracleConnection connection, OracleTransaction? transaction)
+ {
+ using var command = connection.CreateCommand();
+ if (transaction is not null)
+ {
+ command.Transaction = transaction;
+ }
+ command.CommandText = "SELECT SYSTIMESTAMP FROM DUAL";
+ using var reader = command.ExecuteReader();
+ reader.Read();
+ return new Stamp(reader.GetDateTime(0));
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Data/Stores/UserContextStore.cs b/src/SheetMe.Data/Stores/UserContextStore.cs
new file mode 100644
index 0000000..0d3ac1e
--- /dev/null
+++ b/src/SheetMe.Data/Stores/UserContextStore.cs
@@ -0,0 +1,85 @@
+using Oracle.ManagedDataAccess.Client;
+
+namespace SheetMe.Data.Stores;
+
+///
+/// HIS 로그인 사용자 — 감사 컬럼(SdgUidCod/ShtUpdUid 등)에 기록되는 UidCod 의 유일한 출처.
+/// 부서·병원은 표시 전용이며 없어도 인증은 성립한다(레거시 bzLogIn 과 동일 의미론).
+///
+public sealed record HisUser(
+ string UidCod, string UidNam,
+ string DepCod, string DepNam,
+ string HspCod, string HspNam)
+{
+ /// 미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다
+ public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
+
+ /// HIS 사용자로 확인되었는지
+ public bool IsAuthenticated => UidCod.Length > 0;
+
+ /// 표시용 — "홍길동(MSYS)" 형태, 미인증이면 안내 문구
+ public string Display => IsAuthenticated
+ ? (UidNam.Length > 0 ? $"{UidNam}({UidCod})" : UidCod)
+ : "사용자 미확인";
+}
+
+///
+/// 사용자 컨텍스트 조회 — 레거시 bzLogIn.LogIn(uid, autoLogin) 의 신규 대응물.
+/// 레거시도 자동 로그인에서는 비밀번호를 확인하지 않고 M_UidMst 행 존재 + 유효기간만 판정한다
+/// (daLogIn.vb: SELECT * FROM M_UidMst WHERE UidCod=:u AND :adp BETWEEN UidStrDte AND UidEndDte).
+///
+public sealed class UserContextStore
+{
+ #region Member Fields
+ private readonly string connectionString;
+ #endregion
+
+ #region Constructors
+ public UserContextStore(string connectionString)
+ {
+ this.connectionString = connectionString;
+ }
+ #endregion
+
+ #region Methods
+ ///
+ /// UidCod 검증 + 표시 정보 조회 — 미존재이거나 유효기간 밖이면 null.
+ /// 적용일자는 서버 시각을 쓴다(단말 시계가 밀리면 유효 계정이 거부될 수 있다).
+ /// 부서·병원은 LEFT JOIN 이라 마스터에 행이 없어도 인증 자체는 성립한다.
+ ///
+ public HisUser? Resolve(string uidCod)
+ {
+ if (string.IsNullOrWhiteSpace(uidCod))
+ {
+ return null;
+ }
+
+ using var connection = new OracleConnection(connectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.BindByName = true;
+ command.CommandText =
+ "SELECT u.UidCod, NVL(u.UidNam,' '), " +
+ " NVL(u.UidDepCod,' '), NVL(d.DepKorNam,' '), " +
+ " NVL(u.UidHspCod,' '), NVL(h.HspInsNam,' ') " +
+ "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 " +
+ "LEFT JOIN M_HspMst h ON h.HspCod = u.UidHspCod " +
+ " AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN h.HspStrDte AND NVL(h.HspEndDte,'99991231') " +
+ "WHERE u.UidCod = :u " +
+ "AND TO_CHAR(SYSDATE,'YYYYMMDD') BETWEEN u.UidStrDte AND u.UidEndDte";
+ command.Parameters.Add(new OracleParameter("u", uidCod.Trim()));
+
+ using var reader = command.ExecuteReader();
+ if (!reader.Read())
+ {
+ return null;
+ }
+ return new HisUser(
+ reader.GetString(0).Trim(), reader.GetString(1).Trim(),
+ reader.GetString(2).Trim(), reader.GetString(3).Trim(),
+ reader.GetString(4).Trim(), reader.GetString(5).Trim());
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs
index 441caf4..94e323a 100644
--- a/src/SheetMe.Designer/App.xaml.cs
+++ b/src/SheetMe.Designer/App.xaml.cs
@@ -13,87 +13,113 @@ namespace SheetMe.Designer;
///
public partial class App : Application
{
- /// 기동 — 스모크 모드 분기 또는 메인 셸 표시
+ ///
+ /// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
+ /// HIS 기동 규약: SheetMe.Designer.exe "UidCod,ComNum,ShtCod" (콤마 구분 단일 인자).
+ ///
protected override void OnStartup(StartupEventArgs e)
{
base.OnStartup(e);
- if (e.Args.Length >= 3 && e.Args[0] == "--render-smoke")
+ if (Services.StartupArguments.IsDiagnostic(e.Args))
{
- var exitCode = RunRenderSmoke(e.Args[1], e.Args[2]);
- Shutdown(exitCode);
+ Services.UserSession.InitializeDiagnostic();
+ Shutdown(RunDiagnostic(e.Args));
return;
}
- if (e.Args.Length >= 2 && e.Args[0] == "--edit-smoke")
+ var launch = Services.StartupArguments.Parse(e.Args);
+ var config = Services.ConfigLoader.Load();
+ if (!Services.SessionBootstrap.TryInitialize(launch, config.ConnectionString, config.DevUidCod, out var error))
{
- Shutdown(Diagnostics.EditSmoke.Run(e.Args[1]));
- return;
- }
-
- if (e.Args.Length >= 2 && e.Args[0] == "--db-smoke")
- {
- var max = e.Args.Length >= 3 && int.TryParse(e.Args[2], out var n) ? n : 30;
- Shutdown(Diagnostics.DbSmoke.RunReadSmoke(e.Args[1], max));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-save-smoke")
- {
- Shutdown(Diagnostics.DbSmoke.RunSaveSmoke(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-find")
- {
- Shutdown(Diagnostics.DbSmoke.RunFind(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-columns")
- {
- Shutdown(Diagnostics.DbSmoke.RunColumns(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-row")
- {
- Shutdown(Diagnostics.DbSmoke.RunRow(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-sample")
- {
- Shutdown(Diagnostics.DbSmoke.RunSample(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-word-smoke")
- {
- Shutdown(Diagnostics.DbSmoke.RunWordSmoke(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-spd")
- {
- Shutdown(Diagnostics.DbSmoke.RunSpreadDump(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-xml")
- {
- Shutdown(Diagnostics.DbSmoke.RunDesignXml(e.Args[1], e.Args[2]));
- return;
- }
-
- if (e.Args.Length >= 3 && e.Args[0] == "--db-render")
- {
- Shutdown(RunDbRenderSmoke(e.Args[1], e.Args[2]));
+ MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
+ Shutdown(2);
return;
}
Services.ThemeManager.LoadSaved();
- new MainView().Show();
+ var main = new MainView();
+ if (launch?.ShtCod.Length > 0 && main.DataContext is ViewModels.MainViewModel viewModel)
+ {
+ // XAML DataContext 는 InitializeComponent 안에서 이미 생성되므로 Show 전에 넣을 수 있다.
+ // MainView/MainViewModel 생성자를 건드리지 않는 최소 경로.
+ viewModel.PendingSheetCode = launch.ShtCod;
+ }
+ main.Show();
+ }
+
+ /// 진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)
+ private int RunDiagnostic(string[] args)
+ {
+ if (args.Length >= 3 && args[0] == "--render-smoke")
+ {
+ return RunRenderSmoke(args[1], args[2]);
+ }
+
+ if (args.Length >= 2 && args[0] == "--edit-smoke")
+ {
+ return Diagnostics.EditSmoke.Run(args[1]);
+ }
+
+ if (args.Length >= 2 && args[0] == "--db-smoke")
+ {
+ var max = args.Length >= 3 && int.TryParse(args[2], out var n) ? n : 30;
+ return Diagnostics.DbSmoke.RunReadSmoke(args[1], max);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-save-smoke")
+ {
+ return Diagnostics.DbSmoke.RunSaveSmoke(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-find")
+ {
+ return Diagnostics.DbSmoke.RunFind(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-columns")
+ {
+ return Diagnostics.DbSmoke.RunColumns(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-row")
+ {
+ return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-sample")
+ {
+ return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-word-smoke")
+ {
+ return Diagnostics.DbSmoke.RunWordSmoke(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-spd")
+ {
+ return Diagnostics.DbSmoke.RunSpreadDump(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-xml")
+ {
+ return Diagnostics.DbSmoke.RunDesignXml(args[1], args[2]);
+ }
+
+ if (args.Length >= 3 && args[0] == "--db-render")
+ {
+ return RunDbRenderSmoke(args[1], args[2]);
+ }
+
+ if (args.Length >= 2 && args[0] == "--db-gate")
+ {
+ return Diagnostics.DbSmoke.RunGateReport(args[1]);
+ }
+
+ MessageBox.Show($"알 수 없는 진단 옵션입니다: {args[0]}", "서식생성기",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return 2;
}
/// 첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)
diff --git a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
index 0bb3bee..67cfad5 100644
--- a/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
+++ b/src/SheetMe.Designer/DataBusiness/FormDesignDataBusiness.cs
@@ -26,6 +26,15 @@ internal sealed class FormDesignDataBusiness
/// DB 저장 허용 여부 — SaveMode='Db' 명시 설정
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
+
+ /// 현재 HIS 사용자 — 미인증이면 HisUser.Unknown
+ public HisUser User => UserSession.Current;
+
+ /// DB 쓰기 가능 — 접속 + SaveMode='Db' + 사용자 확인. 감사 컬럼에 남길 주체가 있어야 쓴다
+ public bool CanWriteDb => CanSaveToDb && UserSession.IsAuthenticated;
+
+ /// 감사 컬럼 기록 값 — 쓰기 직전에만 접근한다(미인증이면 예외)
+ public string AuditUid => UserSession.AuditUid;
#endregion
#region Methods
@@ -85,15 +94,18 @@ internal sealed class FormDesignDataBusiness
throw new InvalidOperationException(
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
}
- return DbStore().SaveDesign(document, Environment.UserName);
+ return DbStore().SaveDesign(document, AuditUid);
}
/// 서식 코드 등록 여부(E_ShtMst)
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
+ /// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 열기 게이트
+ public SheetDesignPermission GetDesignPermission(string shtCod) => DbStore().GetDesignPermission(shtCod);
+
/// 신규 서식 최소 등록 — 디자이너 서식 관행 기본값
public void RegisterSheet(string shtCod, string korName, string? clsCod)
- => DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
+ => DbStore().RegisterSheet(shtCod, korName, clsCod, AuditUid);
/// 상용구 저장소(E_SHTWRDMST) — DB 접속 필요
public RecordWordStore RecordWords()
diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
index 47c8c7c..b4c723f 100644
--- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs
@@ -1,4 +1,4 @@
-using System.IO;
+using System.IO;
using SheetMe.Core.Models;
using SheetMe.Core.Serialization;
using SheetMe.Data.Stores;
@@ -100,6 +100,54 @@ public static class DbSmoke
}
}
+ ///
+ /// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다.
+ /// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단.
+ ///
+ public static int RunGateReport(string reportPath)
+ {
+ try
+ {
+ var config = ConfigLoader.Load();
+ using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
+ connection.Open();
+ using var command = connection.CreateCommand();
+ command.CommandText =
+ "SELECT NVL(TRIM(s.ShtUsrDesYon),'(미설정)') AS Flag, COUNT(*) AS Total, " +
+ " SUM(CASE WHEN EXISTS (SELECT 1 FROM E_SdgMst d " +
+ " WHERE d.SdgShtCod = s.ShtCod AND NVL(d.SdgDelYon,' ') <> 'Y') " +
+ " THEN 1 ELSE 0 END) AS WithDesign " +
+ "FROM E_ShtMst s WHERE NVL(s.ShtTyp,' ') <> 'S' " +
+ "GROUP BY NVL(TRIM(s.ShtUsrDesYon),'(미설정)') ORDER BY 2 DESC";
+
+ var lines = new List { "ShtUsrDesYon 분포 — 'Y' 와 (미설정)만 열기가 허용된다", string.Empty };
+ var blocked = 0m;
+ using var reader = command.ExecuteReader();
+ while (reader.Read())
+ {
+ var flag = reader.GetString(0);
+ var total = reader.GetDecimal(1);
+ var withDesign = reader.GetDecimal(2);
+ var allowed = flag is "Y" or "(미설정)";
+ if (!allowed)
+ {
+ blocked += withDesign;
+ }
+ lines.Add($" {flag,-10} 전체 {total,5}건 / 디자인 보유 {withDesign,5}건 → {(allowed ? "허용" : "차단")}");
+ }
+ lines.Add(string.Empty);
+ lines.Add($"게이트 도입 시 열기가 차단되는 디자인 보유 서식: {blocked}건");
+
+ File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
+ return 0;
+ }
+ catch (Exception ex)
+ {
+ File.WriteAllText(reportPath, "실패: " + ex.Message);
+ return 1;
+ }
+ }
+
/// 테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)
public static int RunColumns(string tableName, string reportPath)
{
@@ -294,33 +342,33 @@ public static class DbSmoke
// 이전 스모크 잔여 정리
foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구")))
{
- store.Remove(stale.Key);
+ store.Remove(stale.Key, UserSession.AuditUid);
}
var before = store.List(shtCod).Count;
lines.Add($"[사전] {shtCod} 상용구 {before}건");
- var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
- var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
+ var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid);
+ var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid);
var afterAdd = store.List(shtCod);
var okAdd = afterAdd.Count == before + 2
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}");
- store.Update(key1, "스모크 문구 1 (수정됨)", Environment.UserName);
+ store.Update(key1, "스모크 문구 1 (수정됨)", UserSession.AuditUid);
var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
var reordered = afterAdd.Select(w => w.Key).ToList();
reordered.Reverse();
- store.Reorder(reordered, Environment.UserName);
+ store.Reorder(reordered, UserSession.AuditUid);
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
var okReorder = firstKey == reordered[0];
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
- store.Remove(key1);
- store.Remove(key2);
+ store.Remove(key1, UserSession.AuditUid);
+ store.Remove(key2, UserSession.AuditUid);
var afterRemove = store.List(shtCod).Count;
var okRemove = afterRemove == before;
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
@@ -370,7 +418,7 @@ public static class DbSmoke
try
{
target.Props.SetText("Text", probeText);
- store.SaveDesign(document, Environment.UserName);
+ store.SaveDesign(document, UserSession.AuditUid);
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
var keyStable = keyAfter == keyBefore;
@@ -387,7 +435,7 @@ public static class DbSmoke
if (restoreTarget is not null)
{
restoreTarget.Props.SetText("Text", originalText);
- store.SaveDesign(restore, Environment.UserName);
+ store.SaveDesign(restore, UserSession.AuditUid);
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}");
@@ -458,7 +506,7 @@ public static class DbSmoke
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
var document = serializer.Read(before.Xml);
document.FormId = shtCod;
- var savedKey = store.SaveDesign(document, Environment.UserName);
+ var savedKey = store.SaveDesign(document, UserSession.AuditUid);
var after = store.LoadActiveDesignRaw(shtCod)
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
index 3c98487..2485893 100644
--- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
+++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs
@@ -425,7 +425,25 @@ public static class EditSmoke
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
wordEditor.Close();
- // 24) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
+ // 24) 기동 인자 파싱 — 레거시 관용 파싱(PadRight 후 split) 동형
+ Check("인자: 표준 3필드",
+ StartupArguments.ParseRaw("MSYS,,S999") is { UidCod: "MSYS", ShtCod: "S999", ComNum: null });
+ Check("인자: ComNum 포함",
+ StartupArguments.ParseRaw("MSYS,12345,S999") is { ComNum: 12345, ShtCod: "S999" });
+ Check("인자: UidCod 단독",
+ StartupArguments.ParseRaw("MSYS") is { UidCod: "MSYS", ShtCod: "" });
+ Check("인자: 빈 문자열",
+ StartupArguments.ParseRaw("") is { UidCod: "", ShtCod: "" });
+ Check("인자: 필드 초과해도 index 안전",
+ StartupArguments.ParseRaw("MSYS,,S999,X,Y") is { ShtCod: "S999" });
+ Check("인자: 비숫자 ComNum 관용 처리",
+ StartupArguments.ParseRaw("MSYS,abc,S999") is { ComNum: null, ShtCod: "S999" });
+ Check("인자: 진단 플래그는 HIS 인자가 아님",
+ StartupArguments.Parse(new[] { "--db-smoke", "r.txt" }) is null);
+ Check("인자: 인자 없음",
+ StartupArguments.Parse(Array.Empty()) is null);
+
+ // 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
var dirtyDoc = new DesignerViewModel(business.CreateNew());
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
dirtyDoc.AddControlAt("Label", new Point(50, 50));
diff --git a/src/SheetMe.Designer/Services/ConfigLoader.cs b/src/SheetMe.Designer/Services/ConfigLoader.cs
index fdeb211..a522e91 100644
--- a/src/SheetMe.Designer/Services/ConfigLoader.cs
+++ b/src/SheetMe.Designer/Services/ConfigLoader.cs
@@ -31,6 +31,7 @@ public static class ConfigLoader
config.Provider = root["His:Provider"] ?? "Oracle";
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
+ config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
return config;
}
diff --git a/src/SheetMe.Designer/Services/SessionBootstrap.cs b/src/SheetMe.Designer/Services/SessionBootstrap.cs
new file mode 100644
index 0000000..d9aaaf2
--- /dev/null
+++ b/src/SheetMe.Designer/Services/SessionBootstrap.cs
@@ -0,0 +1,71 @@
+using SheetMe.Data.Stores;
+
+namespace SheetMe.Designer.Services;
+
+///
+/// 기동 시 사용자 세션 확정. 레거시 frmSheetDesigner.vb:892 와 동일하게
+/// "인자로 받은 UidCod 가 유효하지 않으면 기동 중단" 이다 — 호출자가 HIS 이므로
+/// 조용히 읽기 전용으로 떨어뜨리면 사용자가 저장이 안 되는 이유를 알 수 없다.
+///
+public static class SessionBootstrap
+{
+ #region Methods
+ ///
+ /// 세션 초기화. 진행 가능하면 true, 기동을 중단해야 하면 false 와 함께 사유를 돌려준다.
+ ///
+ /// 인자에 UidCod 있음 → 조회 성공 시 정상 세션 / 실패 시 기동 중단.
+ /// 인자 없음 → 개발 폴백(His:DevUidCod)을 시도하되 실패해도 미인증으로 계속(DB 쓰기만 차단).
+ /// 접속 문자열이 없으면(파일 전용 모드) 조회 자체를 건너뛴다.
+ ///
+ public static bool TryInitialize(HisLaunch? launch, string connectionString, string devUidCod, out string error)
+ {
+ error = string.Empty;
+ var requestedUid = launch?.UidCod ?? string.Empty;
+
+ if (connectionString.Length == 0)
+ {
+ // DB 접속 자체가 설정되지 않음 — 파일 전용 모드. 사용자 확인은 의미가 없다.
+ UserSession.Initialize(HisUser.Unknown);
+ return true;
+ }
+
+ if (requestedUid.Length == 0)
+ {
+ // 단독 실행: 개발 폴백만 시도하고, 실패해도 기동은 계속한다(DB 쓰기는 차단됨)
+ if (devUidCod.Length > 0)
+ {
+ UserSession.Initialize(SafeResolve(connectionString, devUidCod) ?? HisUser.Unknown);
+ }
+ else
+ {
+ UserSession.Initialize(HisUser.Unknown);
+ }
+ return true;
+ }
+
+ var user = SafeResolve(connectionString, requestedUid);
+ if (user is null)
+ {
+ error = $"사용자 [{requestedUid}] 를 확인할 수 없습니다.\n" +
+ "HIS 사용자 코드가 올바른지, 사용 기간이 유효한지 확인해 주세요.";
+ return false;
+ }
+
+ UserSession.Initialize(user);
+ return true;
+ }
+
+ /// 조회 실패(접속 오류 등)를 null 로 흡수 — 기동 경로에서 예외로 죽지 않게
+ private static HisUser? SafeResolve(string connectionString, string uidCod)
+ {
+ try
+ {
+ return new UserContextStore(connectionString).Resolve(uidCod);
+ }
+ catch
+ {
+ return null;
+ }
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/StartupArguments.cs b/src/SheetMe.Designer/Services/StartupArguments.cs
new file mode 100644
index 0000000..ae855a5
--- /dev/null
+++ b/src/SheetMe.Designer/Services/StartupArguments.cs
@@ -0,0 +1,38 @@
+namespace SheetMe.Designer.Services;
+
+/// HIS 기동 인자 — 레거시 규약 "UidCod,ComNum,ShtCod" (콤마 구분 단일 argv)
+/// HIS 사용자 코드 — 감사 컬럼의 출처
+/// 내원번호 — 레거시에서도 선언·대입만 있고 사용처가 없는 죽은 인자. 형상 보존용
+/// 기동 시 자동으로 열 서식 코드
+/// 원문 — 단일 인스턴스 포워딩에 그대로 전달
+public sealed record HisLaunch(string UidCod, int? ComNum, string ShtCod, string Raw);
+
+///
+/// 기동 인자 파싱. 레거시 frmSheetDesigner.vb:877 의 관용 파싱
+/// ((Command() & "".PadRight(10, ",")).Split(","))을 이식했다 —
+/// 필드가 모자라도 인덱스가 보장되므로 인자 형상이 변해도 깨지지 않는다.
+///
+public static class StartupArguments
+{
+ #region Methods
+ /// 진단 플래그 기동인지 — 첫 인자가 "--" 로 시작
+ public static bool IsDiagnostic(string[] args)
+ => args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal);
+
+ /// HIS 기동 인자 — 진단 모드이거나 인자가 없으면 null
+ public static HisLaunch? Parse(string[] args)
+ => IsDiagnostic(args) || args.Length == 0 ? null : ParseRaw(string.Join(" ", args));
+
+ ///
+ /// 원문 파싱 — 단일 인스턴스 포워딩 수신부와 공용.
+ /// ComNum 은 레거시가 CInt 로 변환하다 비숫자에서 예외를 냈지만, 죽은 값 때문에 기동이
+ /// 실패하면 안 되므로 여기서는 TryParse 로 관용 처리한다.
+ ///
+ public static HisLaunch ParseRaw(string raw)
+ {
+ var parts = ((raw ?? string.Empty) + new string(',', 10)).Split(',');
+ var comNum = int.TryParse(parts[1].Trim(), out var n) ? n : (int?)null;
+ return new HisLaunch(parts[0].Trim(), comNum, parts[2].Trim(), raw ?? string.Empty);
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/Services/UserSession.cs b/src/SheetMe.Designer/Services/UserSession.cs
new file mode 100644
index 0000000..918ac6e
--- /dev/null
+++ b/src/SheetMe.Designer/Services/UserSession.cs
@@ -0,0 +1,54 @@
+using SheetMe.Data.Stores;
+
+namespace SheetMe.Designer.Services;
+
+///
+/// 프로세스 수명 사용자 세션 — 레거시 clsCommonLib.GetInstance.UidMst 싱글턴의 대응물.
+///
+/// 생성자 주입 대신 정적으로 둔 이유: MainViewModel 이 XAML DataContext 로 무인자 생성되고,
+/// 무엇보다 진단 모드(--db-*)는 ViewModel 그래프 밖이라 주입으로는 감사 컬럼 기록 지점에
+/// 도달할 수 없다. 대신 이 정적을 읽는 곳을 App 초기화 / FormDesignDataBusiness /
+/// MainViewModel 표시 세 군데로 제한하고, 저장소는 지금처럼 uid 를 파라미터로만 받는다.
+///
+public static class UserSession
+{
+ #region Member Fields
+ /// 진단 모드 감사 식별자 — DB 에서 즉시 구분되도록(개발자 윈도우 계정명 대신)
+ private const string DiagnosticUid = "SMOKE";
+ #endregion
+
+ #region Properties
+ /// 현재 세션 사용자 — 초기화 전/미인증이면
+ public static HisUser Current { get; private set; } = HisUser.Unknown;
+
+ /// HIS 사용자로 확인되었는지 — DB 쓰기의 전제
+ public static bool IsAuthenticated => Current.IsAuthenticated;
+
+ /// 진단 모드 세션인지
+ public static bool IsDiagnostic { get; private set; }
+
+ ///
+ /// 감사 컬럼에 기록할 사용자 코드. 미인증 상태에서 접근하면 예외 —
+ /// DB 쓰기는 그 전에 로 차단되어야 한다.
+ ///
+ public static string AuditUid => IsAuthenticated
+ ? Current.UidCod
+ : throw new InvalidOperationException("사용자 컨텍스트가 없어 DB 쓰기를 수행할 수 없습니다.");
+ #endregion
+
+ #region Methods
+ /// 세션 확정 — 기동 시 1회
+ public static void Initialize(HisUser user)
+ {
+ Current = user;
+ IsDiagnostic = false;
+ }
+
+ /// 진단 모드 식별자 부여 — --db-* 스모크 전용
+ public static void InitializeDiagnostic()
+ {
+ Current = new HisUser(DiagnosticUid, "진단", string.Empty, string.Empty, string.Empty, string.Empty);
+ IsDiagnostic = true;
+ }
+ #endregion
+}
diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs
index fec4429..a0d946b 100644
--- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs
+++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs
@@ -285,14 +285,60 @@ internal sealed class MainViewModel : ViewModelBase
/// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null
private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
+
+ ///
+ /// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
+ /// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다).
+ ///
+ private bool EnsureDesignAllowed(string shtCod)
+ {
+ try
+ {
+ switch (dataBusiness.GetDesignPermission(shtCod))
+ {
+ case SheetDesignPermission.Allowed:
+ return true;
+ case SheetDesignPermission.NotRegistered:
+ MessageBox.Show($"기록지 정보에 등록되지 않은 서식 코드입니다. ({shtCod})", "확인",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return false;
+ default:
+ MessageBox.Show("서식생성기를 사용하지 않는 서식지입니다.\n기록지 정보를 확인해주세요.", "확인",
+ MessageBoxButton.OK, MessageBoxImage.Information);
+ return false;
+ }
+ }
+ catch (Exception ex)
+ {
+ MessageBox.Show($"서식 권한을 확인하지 못했습니다.\n\n{ex.Message}", "확인",
+ MessageBoxButton.OK, MessageBoxImage.Warning);
+ return false;
+ }
+ }
#endregion
#region Methods - 열기/저장
+ /// 기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅)
+ public string? PendingSheetCode { get; set; }
+
private async Task OnLoadedAsync()
{
+ await LoadSheetListAsync();
+
+ // 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
+ // 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
+ if (PendingSheetCode is { Length: > 0 } shtCod)
+ {
+ PendingSheetCode = null;
+ OpenDbSheet(shtCod);
+ if (OpenDesigners.Count > 0)
+ {
+ return;
+ }
+ }
+
OnNewFile();
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
- await LoadSheetListAsync();
}
private void OnNewFile()
@@ -404,7 +450,11 @@ internal sealed class MainViewModel : ViewModelBase
}
}
- /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화
+ ///
+ /// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
+ /// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
+ /// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다.
+ ///
private void OpenDbSheet(string shtCod)
{
var existing = FindOpenDbDocument(shtCod);
@@ -415,6 +465,11 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
+ if (!EnsureDesignAllowed(shtCod))
+ {
+ return;
+ }
+
var document = dataBusiness.OpenFromDb(shtCod);
if (document is null)
{
@@ -444,10 +499,25 @@ internal sealed class MainViewModel : ViewModelBase
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
return false;
}
+ if (!dataBusiness.CanWriteDb)
+ {
+ MessageBox.Show("HIS 사용자가 확인되지 않아 DB에 저장할 수 없습니다.\n" +
+ "기록지정보 화면에서 서식생성기를 실행하거나, 사용자 코드를 인자로 전달해 주세요.\n" +
+ "(저장 이력에 남길 수정자를 특정할 수 없습니다)",
+ "DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return false;
+ }
CommitPendingEdits();
var document = CurrentDesigner.Document;
+ // 열기 이후 마스터에서 플래그가 꺼졌을 수 있고, 파일→DB 저장은 열기 게이트를 아예 거치지 않는다.
+ // 레거시는 저장 경로에 게이트가 없어 이 구멍이 열려 있었다.
+ if (dataBusiness.SheetExists(document.FormId) && !EnsureDesignAllowed(document.FormId))
+ {
+ return false;
+ }
+
// 미등록 서식이면 신규 등록 다이얼로그
if (!dataBusiness.SheetExists(document.FormId))
{
@@ -661,6 +731,13 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
+ // 파일에서 연 문서의 FormId 가 우연히 거부 서식 코드와 같으면 이 경로로 이력 열람·복원이
+ // 가능해진다(레거시의 우회 경로 중 하나였다).
+ if (!EnsureDesignAllowed(document.FormId))
+ {
+ return;
+ }
+
var versions = dataBusiness.ListVersions(document.FormId);
if (versions.Count == 0)
{
@@ -729,7 +806,8 @@ internal sealed class MainViewModel : ViewModelBase
return;
}
- new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title)
+ new Views.RecordWordDialogView(dataBusiness.RecordWords(), document.FormId, document.Title,
+ dataBusiness.User.UidCod, dataBusiness.CanWriteDb)
{
Owner = Application.Current.MainWindow,
}.ShowDialog();
diff --git a/src/SheetMe.Designer/Views/MainView.xaml b/src/SheetMe.Designer/Views/MainView.xaml
index 70fe856..97f3c82 100644
--- a/src/SheetMe.Designer/Views/MainView.xaml
+++ b/src/SheetMe.Designer/Views/MainView.xaml
@@ -278,6 +278,22 @@
+
+
+
+
+
+
diff --git a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs
index 9001395..cf22c4f 100644
--- a/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs
+++ b/src/SheetMe.Designer/Views/RecordWordDialogView.xaml.cs
@@ -13,23 +13,45 @@ public partial class RecordWordDialogView : Window
#region Member Fields
private readonly RecordWordStore store;
private readonly string shtCod;
+ private readonly string auditUid;
+ private readonly bool canWrite;
private readonly ObservableCollection words = new();
#endregion
#region Constructors
- public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
+ /// 감사 컬럼(ShtUpdUid/ShtFstUid)에 기록할 HIS 사용자 코드
+ /// DB 쓰기 가능 여부 — 미인증/게이트 차단이면 읽기 전용으로 연다
+ public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle, string auditUid, bool canWrite)
{
this.store = store;
this.shtCod = shtCod;
+ this.auditUid = auditUid;
+ this.canWrite = canWrite;
InitializeComponent();
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
- "(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
+ "(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)" +
+ (canWrite ? string.Empty : "\n※ HIS 사용자가 확인되지 않아 읽기 전용입니다.");
WordList.ItemsSource = words;
Loaded += (_, _) => Reload();
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
}
#endregion
+ #region Methods - 가드
+ /// 쓰기 전 확인 — 미인증이면 안내 후 차단(감사 컬럼에 남길 주체가 없다)
+ private bool EnsureWritable()
+ {
+ if (canWrite)
+ {
+ return true;
+ }
+ MessageBox.Show("HIS 사용자가 확인되지 않아 상용구를 변경할 수 없습니다.\n" +
+ "기록지정보 화면에서 서식생성기를 실행해 주세요.",
+ "상용구 관리", MessageBoxButton.OK, MessageBoxImage.Warning);
+ return false;
+ }
+ #endregion
+
#region Methods
private void Reload()
{
@@ -51,13 +73,13 @@ public partial class RecordWordDialogView : Window
private void OnAdd(object sender, RoutedEventArgs e)
{
var value = NewWordBox.Text.Trim();
- if (value.Length == 0)
+ if (value.Length == 0 || !EnsureWritable())
{
return;
}
try
{
- store.Add(shtCod, value, Environment.UserName);
+ store.Add(shtCod, value, auditUid);
NewWordBox.Clear();
NewWordBox.Focus();
Reload();
@@ -72,7 +94,7 @@ public partial class RecordWordDialogView : Window
private void OnEdit(object sender, RoutedEventArgs e)
{
- if (WordList.SelectedItem is not RecordWordInfo selected)
+ if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{
return;
}
@@ -89,7 +111,7 @@ public partial class RecordWordDialogView : Window
}
try
{
- store.Update(selected.Key, newValue, Environment.UserName);
+ store.Update(selected.Key, newValue, auditUid);
Reload();
}
catch (Exception ex)
@@ -101,7 +123,7 @@ public partial class RecordWordDialogView : Window
private void OnRemove(object sender, RoutedEventArgs e)
{
- if (WordList.SelectedItem is not RecordWordInfo selected)
+ if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
{
return;
}
@@ -112,7 +134,7 @@ public partial class RecordWordDialogView : Window
}
try
{
- store.Remove(selected.Key);
+ store.Remove(selected.Key, auditUid);
Reload();
}
catch (Exception ex)
@@ -130,14 +152,14 @@ public partial class RecordWordDialogView : Window
{
var index = WordList.SelectedIndex;
var target = index + delta;
- if (index < 0 || target < 0 || target >= words.Count)
+ if (index < 0 || target < 0 || target >= words.Count || !EnsureWritable())
{
return;
}
try
{
words.Move(index, target);
- store.Reorder(words.Select(w => w.Key).ToList(), Environment.UserName);
+ store.Reorder(words.Select(w => w.Key).ToList(), auditUid);
WordList.SelectedIndex = target;
}
catch (Exception ex)