HIS 사용자 컨텍스트 + 서식생성기 권한 게이트 + 서버 시각 전환
[사용자 컨텍스트] 감사 컬럼에 Windows 계정명이 들어가던 6지점을 HIS UidCod 로 교체했다. 레거시 이력 화면(ucSheetHistory.vb:51)이 SdgUidCod 를 수정자로 표시하므로 지금까지는 감사 추적이 끊겨 있었다. - UserContextStore — M_UidMst 단독 조회로 인증 판정(레거시 bzLogIn 자동로그인과 동일 의미론, 비밀번호를 보지 않고 행 존재 + 유효기간만 확인). 부서/병원은 LEFT JOIN best-effort 표시용. 적용일자는 서버 시각을 쓴다 — 단말 시계가 밀리면 유효 계정이 거부될 수 있다. - UserSession — 정적 set-once. 생성자 주입을 택하지 않은 이유는 진단 모드(--db-*)가 ViewModel 그래프 밖이라 주입으로는 감사 기록 지점에 닿을 수 없기 때문이다. 대신 읽는 곳을 App 초기화 / FormDesignDataBusiness / MainViewModel 표시 세 군데로 제한했고, 저장소는 지금처럼 uid 를 파라미터로만 받는다. 진단 모드는 "SMOKE" 식별자를 쓴다. - StartupArguments — 레거시 규약 "UidCod,ComNum,ShtCod" 관용 파싱(PadRight 후 split) 이식. ComNum 은 레거시에서도 선언·대입뿐인 죽은 인자라 보존만 하고 쓰지 않는다. - App.OnStartup 재구성 — 진단 플래그 12종을 RunDiagnostic 으로 분리(각 분기가 종료 코드를 반환하도록 정리), 세션 확정 후 MainView 표시. 기동 인자에 ShtCod 가 있으면 PendingSheetCode 로 넘겨 빈 새 문서를 만들지 않고 그 서식만 연다(열기 실패 시에는 빈 문서로 폴백). - 인자로 받은 UidCod 조회에 실패하면 기동을 중단한다(레거시 :892 와 동형). 인자 없이 단독 실행하면 미인증으로 계속하되 DB 쓰기를 차단하고, 개발 편의는 His:DevUidCod 설정으로 뺐다. [권한 게이트] E_ShtMst.ShtUsrDesYon — 'Y' 또는 미설정이면 허용, 그 외만 거부(레거시 frmSheetDesigner.vb:71-84 규칙, 거부 문구도 그대로). 레거시는 게이트가 열기 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했는데, SheetMe 는 모든 열기가 OpenDbSheet 로 수렴하므로 거기 한 곳이면 구조적으로 우회가 불가능하다. 추가로 이력 열람과 DB 저장에도 넣었다 — 후자는 레거시에 아예 없던 구멍이다(열기 이후 플래그가 꺼질 수 있고, 파일에서 연 문서를 DB 에 저장하는 경로는 열기 게이트를 거치지 않는다). 목록에서 거르지 않고 잠금 아이콘으로 표시한다(안 보이면 사용자가 진단할 수 없다). --db-gate 진단 신설 — 실측 결과 차단되는 디자인 보유 서식 0건(N 은 1건뿐이고 디자인 없음). [서버 시각] ServerClock 신설(SELECT SYSTIMESTAMP 왕복 1회로 8/12/17자리 확보, 포맷은 invariant 로 C# 에서 — NLS 의존 제거). 감사 정확성뿐 아니라 ResolveActiveSdgKey 가 ORDER BY SdgUpdDtm 으로 활성 버전을 고르기 때문에, 시계가 뒤로 밀린 단말이 저장하면 활성 버전 판정이 뒤집힐 수 있었다. Reorder 는 루프 밖 1회로 바꿔 한 번의 순서 변경이 행마다 다른 시각을 남기지 않게 했다. 실패 시 폴백하지 않는다. RecordWordStore.Remove 에 auditUid 인자 추가 — 남길 컬럼은 없지만(런타임 조회 쿼리에 ShtStt 필터가 없어 논리 삭제로 바꾸면 지운 문구가 계속 노출된다) "모든 쓰기 API 는 uid 를 명시한다"는 규약을 컴파일 타임에 강제한다. 상용구 대화상자는 uid 를 생성자로 받고, 미인증이면 읽기 전용으로 연다. 검증: 사전 실측으로 M_UidMst/M_DepMst/M_HspMst 접근 확인(2단계 최대 리스크 해소, 유효 사용자 539명). 테스트 70/70, edit-smoke 실패 0(기동 인자 파싱 8건 추가), 왕복 1,271건 diff 0/예외 0, db-save-smoke 제자리 갱신 통과, db-word-smoke 통과. DB 직접 확인: P163 저장분 SdgUidCod='SMOKE', SdgUpdDtm 이 서버 시각과 일치 (변경 전 저장분 S999 는 'Msystech' 로 대비됨). 남은 항목: 단일 인스턴스 + Named Pipe IPC 는 미구현. 기동 인자·게이트가 먼저 필요했고, 바로가기 운영에서는 중복 실행 억제가 덜 급하다. 다중 사용자 병행 운영 전에 처리한다. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5
parent
5c97bf1440
commit
0f77cb717a
@@ -18,6 +18,13 @@ public sealed class DataConfig
|
|||||||
|
|
||||||
/// <summary>파일 모드 서식 폴더</summary>
|
/// <summary>파일 모드 서식 폴더</summary>
|
||||||
public string XmlFolder { get; set; } = "forms";
|
public string XmlFolder { get; set; } = "forms";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 개발용 HIS 사용자 코드 — 기동 인자 없이 실행할 때만 시도하는 폴백.
|
||||||
|
/// 운영 배포본의 appsettings.json 에는 이 키가 없으므로 자동으로 비활성이다.
|
||||||
|
/// 여기 담는 것은 설정값(UidCod 문자열)뿐이며, 해석된 사용자 정보는 담지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
public string DevUidCod { get; set; } = string.Empty;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
|
|||||||
@@ -5,7 +5,21 @@ using SheetMe.Core.Serialization;
|
|||||||
namespace SheetMe.Data.Stores;
|
namespace SheetMe.Data.Stores;
|
||||||
|
|
||||||
/// <summary>서식 목록 1건(열기 대화상자)</summary>
|
/// <summary>서식 목록 1건(열기 대화상자)</summary>
|
||||||
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign);
|
/// <param name="DesignAllowed">서식생성기 사용 허용(E_ShtMst.ShtUsrDesYon) — 목록 잠금 표시용</param>
|
||||||
|
public sealed record SheetSummary(string ShtCod, string Name, bool HasDesign, bool DesignAllowed);
|
||||||
|
|
||||||
|
/// <summary>서식생성기 사용 권한 판정 결과</summary>
|
||||||
|
public enum SheetDesignPermission
|
||||||
|
{
|
||||||
|
/// <summary>E_ShtMst 에 서식 코드가 없음</summary>
|
||||||
|
NotRegistered,
|
||||||
|
|
||||||
|
/// <summary>허용 — ShtUsrDesYon 이 'Y' 또는 미설정</summary>
|
||||||
|
Allowed,
|
||||||
|
|
||||||
|
/// <summary>거부 — ShtUsrDesYon 이 'N' 등</summary>
|
||||||
|
Denied,
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>디자인 버전 1건</summary>
|
/// <summary>디자인 버전 1건</summary>
|
||||||
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
|
public sealed record DesignVersionInfo(decimal SdgKey, string UpdDtm, string UpdUid, bool Deleted);
|
||||||
@@ -54,7 +68,8 @@ public sealed class OracleLegacyFormStore
|
|||||||
command.CommandText =
|
command.CommandText =
|
||||||
"SELECT * FROM (" +
|
"SELECT * FROM (" +
|
||||||
" SELECT s.ShtCod, s.ShtKorNam," +
|
" 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" +
|
$" FROM E_ShtMst s {where} ORDER BY s.ShtCod" +
|
||||||
$") WHERE ROWNUM <= {max}";
|
$") WHERE ROWNUM <= {max}";
|
||||||
|
|
||||||
@@ -65,11 +80,36 @@ public sealed class OracleLegacyFormStore
|
|||||||
result.Add(new SheetSummary(
|
result.Add(new SheetSummary(
|
||||||
reader.GetString(0),
|
reader.GetString(0),
|
||||||
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
reader.IsDBNull(1) ? string.Empty : reader.GetString(1),
|
||||||
reader.GetDecimal(2) > 0));
|
reader.GetDecimal(2) > 0,
|
||||||
|
IsDesignAllowed(reader.GetString(3))));
|
||||||
}
|
}
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 frmSheetDesigner.vb:71-84 규칙.
|
||||||
|
/// 'Y' 또는 빈 문자열/NULL(미설정 = 레거시 데이터 호환)이면 허용, 그 외('N' 등)만 거부한다.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>ShtUsrDesYon 판정 — CHAR 패딩을 흡수하려 TRIM 후 비교(레거시보다 관대한 방향이라 오탐만 줄인다)</summary>
|
||||||
|
private static bool IsDesignAllowed(string flag)
|
||||||
|
{
|
||||||
|
var trimmed = flag.Trim();
|
||||||
|
return trimmed is "Y" or "";
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary>
|
/// <summary>활성(비삭제 최신) 디자인 로드 — 없으면 null. FormId/Title/SourceSdgKey 채움</summary>
|
||||||
public FormDocument? LoadActiveDesign(string shtCod)
|
public FormDocument? LoadActiveDesign(string shtCod)
|
||||||
{
|
{
|
||||||
@@ -214,12 +254,15 @@ public sealed class OracleLegacyFormStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
var mergedXml = serializer.Write(document);
|
var mergedXml = serializer.Write(document);
|
||||||
var now = DateTime.Now.ToString("yyyyMMddHHmm"); // 레거시 SystemDateTime 포맷(12자리)
|
|
||||||
|
|
||||||
using var connection = Open();
|
using var connection = Open();
|
||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 레거시 SystemDateTime 과 동일하게 DB 서버 시각을 쓴다 — 단말 시계가 밀리면
|
||||||
|
// ResolveActiveSdgKey 의 ORDER BY SdgUpdDtm 이 활성 버전을 잘못 고를 수 있다.
|
||||||
|
var now = ServerClock.Read(connection, transaction).Minute12;
|
||||||
|
|
||||||
var shtCneYon = Scalar(connection, transaction,
|
var shtCneYon = Scalar(connection, transaction,
|
||||||
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
|
"SELECT NVL(ShtCneYon,' ') FROM E_ShtMst WHERE ShtCod = :s",
|
||||||
new OracleParameter("s", document.FormId)) as string;
|
new OracleParameter("s", document.FormId)) as string;
|
||||||
@@ -302,17 +345,19 @@ public sealed class OracleLegacyFormStore
|
|||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
var stamp = ServerClock.Read(connection, transaction);
|
||||||
|
// ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다
|
||||||
NonQuery(connection, transaction,
|
NonQuery(connection, transaction,
|
||||||
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
|
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
|
||||||
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
|
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
|
||||||
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
|
"ShtEleSig, ShtUpdUid, ShtUpdDtm) " +
|
||||||
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
|
"VALUES (:cod, :adp, :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
|
||||||
new OracleParameter("cod", shtCod),
|
new OracleParameter("cod", shtCod),
|
||||||
new OracleParameter("adp", DateTime.Now.ToString("yyyyMMdd")),
|
new OracleParameter("adp", stamp.Date8),
|
||||||
new OracleParameter("nam", korName),
|
new OracleParameter("nam", korName),
|
||||||
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
|
new OracleParameter("cls", string.IsNullOrWhiteSpace(clsCod) ? (object)DBNull.Value : clsCod),
|
||||||
new OracleParameter("uid", uid),
|
new OracleParameter("uid", uid),
|
||||||
new OracleParameter("dtm", DateTime.Now.ToString("yyyyMMddHHmm")));
|
new OracleParameter("dtm", stamp.Minute12));
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
}
|
}
|
||||||
catch
|
catch
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Oracle.ManagedDataAccess.Client;
|
using Oracle.ManagedDataAccess.Client;
|
||||||
|
|
||||||
namespace SheetMe.Data.Stores;
|
namespace SheetMe.Data.Stores;
|
||||||
|
|
||||||
@@ -54,6 +54,8 @@ public sealed class RecordWordStore
|
|||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 서버 시각 1회 — RETURNING 경로와 폴백 경로가 같은 값을 쓰게 한다
|
||||||
|
var stamp = ServerClock.Read(connection, transaction).Milli17;
|
||||||
var seq = Convert.ToDecimal(Scalar(connection, transaction,
|
var seq = Convert.ToDecimal(Scalar(connection, transaction,
|
||||||
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
|
"SELECT NVL(MAX(ShtDspSeq),0)+1 FROM E_SHTWRDMST WHERE ShtCod = :s AND ShtStpLvl = 1",
|
||||||
new OracleParameter("s", shtCod))!);
|
new OracleParameter("s", shtCod))!);
|
||||||
@@ -74,7 +76,7 @@ public sealed class RecordWordStore
|
|||||||
command.Parameters.Add(new OracleParameter("v", value));
|
command.Parameters.Add(new OracleParameter("v", value));
|
||||||
command.Parameters.Add(new OracleParameter("q", seq));
|
command.Parameters.Add(new OracleParameter("q", seq));
|
||||||
command.Parameters.Add(new OracleParameter("u", uid));
|
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);
|
var outKey = new OracleParameter("outKey", OracleDbType.Decimal, System.Data.ParameterDirection.Output);
|
||||||
command.Parameters.Add(outKey);
|
command.Parameters.Add(outKey);
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
@@ -94,7 +96,7 @@ public sealed class RecordWordStore
|
|||||||
new OracleParameter("v", value),
|
new OracleParameter("v", value),
|
||||||
new OracleParameter("q", seq),
|
new OracleParameter("q", seq),
|
||||||
new OracleParameter("u", uid),
|
new OracleParameter("u", uid),
|
||||||
new OracleParameter("d", Now17()));
|
new OracleParameter("d", stamp));
|
||||||
}
|
}
|
||||||
|
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
@@ -111,20 +113,28 @@ public sealed class RecordWordStore
|
|||||||
public void Update(decimal key, string value, string uid)
|
public void Update(decimal key, string value, string uid)
|
||||||
{
|
{
|
||||||
using var connection = Open();
|
using var connection = Open();
|
||||||
|
var stamp = ServerClock.Read(connection, null).Milli17;
|
||||||
using var command = connection.CreateCommand();
|
using var command = connection.CreateCommand();
|
||||||
command.BindByName = true;
|
command.BindByName = true;
|
||||||
command.CommandText =
|
command.CommandText =
|
||||||
"UPDATE E_SHTWRDMST SET ShtWrdVal = :v, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k";
|
"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("v", value));
|
||||||
command.Parameters.Add(new OracleParameter("u", uid));
|
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.Parameters.Add(new OracleParameter("k", key));
|
||||||
command.ExecuteNonQuery();
|
command.ExecuteNonQuery();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>상용구 삭제(하위 단계 포함)</summary>
|
/// <summary>
|
||||||
public void Remove(decimal key)
|
/// 상용구 삭제(하위 단계 포함) — 물리 DELETE.
|
||||||
|
/// auditUid 를 DB 에 남길 곳은 없다: 레거시 런타임 조회 쿼리(dtRecWordMst.vb)에 ShtStt 필터가 없어
|
||||||
|
/// 논리 삭제로 바꾸면 지운 문구가 EMR 작성 화면에 계속 노출된다. 그럼에도 인자로 받는 이유는
|
||||||
|
/// "모든 쓰기 API 는 uid 를 명시한다"는 규약을 컴파일 타임에 강제해, 나중에 uid 없는 경로가
|
||||||
|
/// 새로 생기지 않게 하기 위해서다.
|
||||||
|
/// </summary>
|
||||||
|
public void Remove(decimal key, string auditUid)
|
||||||
{
|
{
|
||||||
|
_ = auditUid;
|
||||||
using var connection = Open();
|
using var connection = Open();
|
||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
@@ -149,13 +159,15 @@ public sealed class RecordWordStore
|
|||||||
using var transaction = connection.BeginTransaction();
|
using var transaction = connection.BeginTransaction();
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
|
// 루프 밖에서 1회 — 한 번의 순서 변경이 행마다 다른 시각을 남기지 않게 한다
|
||||||
|
var stamp = ServerClock.Read(connection, transaction).Milli17;
|
||||||
for (var i = 0; i < orderedKeys.Count; i++)
|
for (var i = 0; i < orderedKeys.Count; i++)
|
||||||
{
|
{
|
||||||
NonQuery(connection, transaction,
|
NonQuery(connection, transaction,
|
||||||
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
|
"UPDATE E_SHTWRDMST SET ShtDspSeq = :q, ShtUpdUid = :u, ShtUpdDtf = :d WHERE ShtWrdKey = :k",
|
||||||
new OracleParameter("q", (decimal)(i + 1)),
|
new OracleParameter("q", (decimal)(i + 1)),
|
||||||
new OracleParameter("u", uid),
|
new OracleParameter("u", uid),
|
||||||
new OracleParameter("d", Now17()),
|
new OracleParameter("d", stamp),
|
||||||
new OracleParameter("k", orderedKeys[i]));
|
new OracleParameter("k", orderedKeys[i]));
|
||||||
}
|
}
|
||||||
transaction.Commit();
|
transaction.Commit();
|
||||||
@@ -174,9 +186,6 @@ public sealed class RecordWordStore
|
|||||||
return connection;
|
return connection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>SHTUPDDTF 17자리(yyyyMMddHHmmssfff)</summary>
|
|
||||||
private static string Now17() => DateTime.Now.ToString("yyyyMMddHHmmssfff");
|
|
||||||
|
|
||||||
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
|
private static object? Scalar(OracleConnection connection, OracleTransaction transaction, string sql, params OracleParameter[] parameters)
|
||||||
{
|
{
|
||||||
using var command = connection.CreateCommand();
|
using var command = connection.CreateCommand();
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.Globalization;
|
||||||
|
using Oracle.ManagedDataAccess.Client;
|
||||||
|
|
||||||
|
namespace SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 감사 컬럼 시각의 유일한 출처 = DB 서버 시각(레거시 <c>clsCommonLib.SystemDateTime</c> 동형 —
|
||||||
|
/// 레거시도 <c>SELECT Sysdate FROM DUAL</c> 로 서버 시각을 받아 클라이언트에서 포맷한다).
|
||||||
|
///
|
||||||
|
/// 단순한 감사 정확성 문제가 아니다. <c>ResolveActiveSdgKey</c> 가
|
||||||
|
/// <c>ORDER BY SdgUpdDtm DESC, SdgKey DESC</c> 로 활성 버전을 고르므로,
|
||||||
|
/// PC 시계가 뒤로 밀린 단말에서 저장하면 활성 버전 판정이 뒤집힐 수 있다.
|
||||||
|
/// </summary>
|
||||||
|
internal static class ServerClock
|
||||||
|
{
|
||||||
|
#region Types
|
||||||
|
/// <summary>서버 시각 1회 조회 결과 — 레거시가 쓰는 3가지 포맷을 함께 제공</summary>
|
||||||
|
internal readonly record struct Stamp(DateTime Value)
|
||||||
|
{
|
||||||
|
/// <summary>yyyyMMdd (8자리) — ShtAdpDte 류</summary>
|
||||||
|
public string Date8 => Value.ToString("yyyyMMdd", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>yyyyMMddHHmm (12자리) — SdgUpdDtm/ShtUpdDtm</summary>
|
||||||
|
public string Minute12 => Value.ToString("yyyyMMddHHmm", CultureInfo.InvariantCulture);
|
||||||
|
|
||||||
|
/// <summary>yyyyMMddHHmmssfff (17자리) — 상용구 ShtUpdDtf/ShtFstDtf</summary>
|
||||||
|
public string Milli17 => Value.ToString("yyyyMMddHHmmssfff", CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 서버 시각 조회 — 왕복 1회로 3포맷을 확보한다.
|
||||||
|
/// 포맷 마스크를 SQL 에 두지 않고 값만 받아 invariant 로 포맷해 NLS 설정 의존을 없앤다.
|
||||||
|
/// 실패 시 폴백하지 않는다 — 커넥션이 죽은 것이고 뒤따르는 DML 도 어차피 실패한다.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
using Oracle.ManagedDataAccess.Client;
|
||||||
|
|
||||||
|
namespace SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// HIS 로그인 사용자 — 감사 컬럼(SdgUidCod/ShtUpdUid 등)에 기록되는 UidCod 의 유일한 출처.
|
||||||
|
/// 부서·병원은 표시 전용이며 없어도 인증은 성립한다(레거시 bzLogIn 과 동일 의미론).
|
||||||
|
/// </summary>
|
||||||
|
public sealed record HisUser(
|
||||||
|
string UidCod, string UidNam,
|
||||||
|
string DepCod, string DepNam,
|
||||||
|
string HspCod, string HspNam)
|
||||||
|
{
|
||||||
|
/// <summary>미인증 세션(인자 없이 단독 실행) — DB 쓰기가 차단된다</summary>
|
||||||
|
public static readonly HisUser Unknown = new(string.Empty, string.Empty, string.Empty, string.Empty, string.Empty, string.Empty);
|
||||||
|
|
||||||
|
/// <summary>HIS 사용자로 확인되었는지</summary>
|
||||||
|
public bool IsAuthenticated => UidCod.Length > 0;
|
||||||
|
|
||||||
|
/// <summary>표시용 — "홍길동(MSYS)" 형태, 미인증이면 안내 문구</summary>
|
||||||
|
public string Display => IsAuthenticated
|
||||||
|
? (UidNam.Length > 0 ? $"{UidNam}({UidCod})" : UidCod)
|
||||||
|
: "사용자 미확인";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 사용자 컨텍스트 조회 — 레거시 bzLogIn.LogIn(uid, autoLogin) 의 신규 대응물.
|
||||||
|
/// 레거시도 자동 로그인에서는 비밀번호를 확인하지 않고 M_UidMst 행 존재 + 유효기간만 판정한다
|
||||||
|
/// (daLogIn.vb: SELECT * FROM M_UidMst WHERE UidCod=:u AND :adp BETWEEN UidStrDte AND UidEndDte).
|
||||||
|
/// </summary>
|
||||||
|
public sealed class UserContextStore
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
private readonly string connectionString;
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Constructors
|
||||||
|
public UserContextStore(string connectionString)
|
||||||
|
{
|
||||||
|
this.connectionString = connectionString;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// UidCod 검증 + 표시 정보 조회 — 미존재이거나 유효기간 밖이면 null.
|
||||||
|
/// 적용일자는 서버 시각을 쓴다(단말 시계가 밀리면 유효 계정이 거부될 수 있다).
|
||||||
|
/// 부서·병원은 LEFT JOIN 이라 마스터에 행이 없어도 인증 자체는 성립한다.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -13,87 +13,113 @@ namespace SheetMe.Designer;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public partial class App : Application
|
public partial class App : Application
|
||||||
{
|
{
|
||||||
/// <summary>기동 — 스모크 모드 분기 또는 메인 셸 표시</summary>
|
/// <summary>
|
||||||
|
/// 기동 — 진단 모드 분기, 아니면 HIS 세션을 확정한 뒤 메인 셸 표시.
|
||||||
|
/// HIS 기동 규약: <c>SheetMe.Designer.exe "UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 인자).
|
||||||
|
/// </summary>
|
||||||
protected override void OnStartup(StartupEventArgs e)
|
protected override void OnStartup(StartupEventArgs e)
|
||||||
{
|
{
|
||||||
base.OnStartup(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]);
|
Services.UserSession.InitializeDiagnostic();
|
||||||
Shutdown(exitCode);
|
Shutdown(RunDiagnostic(e.Args));
|
||||||
return;
|
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]));
|
MessageBox.Show(error, "서식생성기", MessageBoxButton.OK, MessageBoxImage.Error);
|
||||||
return;
|
Shutdown(2);
|
||||||
}
|
|
||||||
|
|
||||||
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]));
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Services.ThemeManager.LoadSaved();
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>진단 플래그 분기 — 종료 코드를 반환한다(호출부가 Shutdown 처리)</summary>
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
|
/// <summary>첫 페이지 오프스크린 렌더 → PNG (렌더 파이프라인 자동 검증)</summary>
|
||||||
|
|||||||
@@ -26,6 +26,15 @@ internal sealed class FormDesignDataBusiness
|
|||||||
|
|
||||||
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
|
/// <summary>DB 저장 허용 여부 — SaveMode='Db' 명시 설정</summary>
|
||||||
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
|
public bool CanSaveToDb => CanUseDb && Config.IsDbSaveMode();
|
||||||
|
|
||||||
|
/// <summary>현재 HIS 사용자 — 미인증이면 HisUser.Unknown</summary>
|
||||||
|
public HisUser User => UserSession.Current;
|
||||||
|
|
||||||
|
/// <summary>DB 쓰기 가능 — 접속 + SaveMode='Db' + 사용자 확인. 감사 컬럼에 남길 주체가 있어야 쓴다</summary>
|
||||||
|
public bool CanWriteDb => CanSaveToDb && UserSession.IsAuthenticated;
|
||||||
|
|
||||||
|
/// <summary>감사 컬럼 기록 값 — 쓰기 직전에만 접근한다(미인증이면 예외)</summary>
|
||||||
|
public string AuditUid => UserSession.AuditUid;
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
@@ -85,15 +94,18 @@ internal sealed class FormDesignDataBusiness
|
|||||||
throw new InvalidOperationException(
|
throw new InvalidOperationException(
|
||||||
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
|
"DB 저장이 비활성화되어 있습니다. appsettings.json 의 FormStore:SaveMode 를 'Db' 로 설정하세요.");
|
||||||
}
|
}
|
||||||
return DbStore().SaveDesign(document, Environment.UserName);
|
return DbStore().SaveDesign(document, AuditUid);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
|
/// <summary>서식 코드 등록 여부(E_ShtMst)</summary>
|
||||||
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
|
public bool SheetExists(string shtCod) => DbStore().SheetExists(shtCod);
|
||||||
|
|
||||||
|
/// <summary>서식생성기 사용 허용 여부(E_ShtMst.ShtUsrDesYon) — 레거시 열기 게이트</summary>
|
||||||
|
public SheetDesignPermission GetDesignPermission(string shtCod) => DbStore().GetDesignPermission(shtCod);
|
||||||
|
|
||||||
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
|
/// <summary>신규 서식 최소 등록 — 디자이너 서식 관행 기본값</summary>
|
||||||
public void RegisterSheet(string shtCod, string korName, string? clsCod)
|
public void RegisterSheet(string shtCod, string korName, string? clsCod)
|
||||||
=> DbStore().RegisterSheet(shtCod, korName, clsCod, Environment.UserName);
|
=> DbStore().RegisterSheet(shtCod, korName, clsCod, AuditUid);
|
||||||
|
|
||||||
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
|
/// <summary>상용구 저장소(E_SHTWRDMST) — DB 접속 필요</summary>
|
||||||
public RecordWordStore RecordWords()
|
public RecordWordStore RecordWords()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using System.IO;
|
using System.IO;
|
||||||
using SheetMe.Core.Models;
|
using SheetMe.Core.Models;
|
||||||
using SheetMe.Core.Serialization;
|
using SheetMe.Core.Serialization;
|
||||||
using SheetMe.Data.Stores;
|
using SheetMe.Data.Stores;
|
||||||
@@ -100,6 +100,54 @@ public static class DbSmoke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다.
|
||||||
|
/// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단.
|
||||||
|
/// </summary>
|
||||||
|
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<string> { "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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
/// <summary>테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용)</summary>
|
||||||
public static int RunColumns(string tableName, string reportPath)
|
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("스모크 문구")))
|
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;
|
var before = store.List(shtCod).Count;
|
||||||
lines.Add($"[사전] {shtCod} 상용구 {before}건");
|
lines.Add($"[사전] {shtCod} 상용구 {before}건");
|
||||||
|
|
||||||
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", Environment.UserName);
|
var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid);
|
||||||
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", Environment.UserName);
|
var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid);
|
||||||
var afterAdd = store.List(shtCod);
|
var afterAdd = store.List(shtCod);
|
||||||
var okAdd = afterAdd.Count == before + 2
|
var okAdd = afterAdd.Count == before + 2
|
||||||
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
|
&& afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2);
|
||||||
lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}");
|
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 updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value;
|
||||||
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
|
var okUpdate = updatedValue == "스모크 문구 1 (수정됨)";
|
||||||
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
|
lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}");
|
||||||
|
|
||||||
var reordered = afterAdd.Select(w => w.Key).ToList();
|
var reordered = afterAdd.Select(w => w.Key).ToList();
|
||||||
reordered.Reverse();
|
reordered.Reverse();
|
||||||
store.Reorder(reordered, Environment.UserName);
|
store.Reorder(reordered, UserSession.AuditUid);
|
||||||
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
|
var firstKey = store.List(shtCod).FirstOrDefault()?.Key;
|
||||||
var okReorder = firstKey == reordered[0];
|
var okReorder = firstKey == reordered[0];
|
||||||
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
|
lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}");
|
||||||
|
|
||||||
store.Remove(key1);
|
store.Remove(key1, UserSession.AuditUid);
|
||||||
store.Remove(key2);
|
store.Remove(key2, UserSession.AuditUid);
|
||||||
var afterRemove = store.List(shtCod).Count;
|
var afterRemove = store.List(shtCod).Count;
|
||||||
var okRemove = afterRemove == before;
|
var okRemove = afterRemove == before;
|
||||||
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
|
lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}");
|
||||||
@@ -370,7 +418,7 @@ public static class DbSmoke
|
|||||||
try
|
try
|
||||||
{
|
{
|
||||||
target.Props.SetText("Text", probeText);
|
target.Props.SetText("Text", probeText);
|
||||||
store.SaveDesign(document, Environment.UserName);
|
store.SaveDesign(document, UserSession.AuditUid);
|
||||||
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
|
var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id);
|
||||||
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
|
var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal);
|
||||||
var keyStable = keyAfter == keyBefore;
|
var keyStable = keyAfter == keyBefore;
|
||||||
@@ -387,7 +435,7 @@ public static class DbSmoke
|
|||||||
if (restoreTarget is not null)
|
if (restoreTarget is not null)
|
||||||
{
|
{
|
||||||
restoreTarget.Props.SetText("Text", originalText);
|
restoreTarget.Props.SetText("Text", originalText);
|
||||||
store.SaveDesign(restore, Environment.UserName);
|
store.SaveDesign(restore, UserSession.AuditUid);
|
||||||
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
|
var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id);
|
||||||
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
|
lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " +
|
||||||
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}");
|
$"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}");
|
||||||
@@ -458,7 +506,7 @@ public static class DbSmoke
|
|||||||
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
|
// 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신)
|
||||||
var document = serializer.Read(before.Xml);
|
var document = serializer.Read(before.Xml);
|
||||||
document.FormId = shtCod;
|
document.FormId = shtCod;
|
||||||
var savedKey = store.SaveDesign(document, Environment.UserName);
|
var savedKey = store.SaveDesign(document, UserSession.AuditUid);
|
||||||
|
|
||||||
var after = store.LoadActiveDesignRaw(shtCod)
|
var after = store.LoadActiveDesignRaw(shtCod)
|
||||||
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
|
?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패");
|
||||||
|
|||||||
@@ -425,7 +425,25 @@ public static class EditSmoke
|
|||||||
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
|
Check("상용구 모드: 제목에 '쿼리' 없음", !wordEditor.Title.Contains("쿼리"), wordEditor.Title);
|
||||||
wordEditor.Close();
|
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<string>()) is null);
|
||||||
|
|
||||||
|
// 25) 더티 추적 — 종료 가드의 판단 근거(별도 문서로 격리해 위 단계와 간섭 없게)
|
||||||
var dirtyDoc = new DesignerViewModel(business.CreateNew());
|
var dirtyDoc = new DesignerViewModel(business.CreateNew());
|
||||||
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
|
Check("더티: 초기 상태는 clean", !dirtyDoc.Undo.IsDirty);
|
||||||
dirtyDoc.AddControlAt("Label", new Point(50, 50));
|
dirtyDoc.AddControlAt("Label", new Point(50, 50));
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ public static class ConfigLoader
|
|||||||
config.Provider = root["His:Provider"] ?? "Oracle";
|
config.Provider = root["His:Provider"] ?? "Oracle";
|
||||||
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
|
config.SaveMode = root["FormStore:SaveMode"] ?? "File";
|
||||||
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
|
config.XmlFolder = root["FormStore:XmlFolder"] ?? "forms";
|
||||||
|
config.DevUidCod = root["His:DevUidCod"] ?? string.Empty;
|
||||||
return config;
|
return config;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
using SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 기동 시 사용자 세션 확정. 레거시 frmSheetDesigner.vb:892 와 동일하게
|
||||||
|
/// "인자로 받은 UidCod 가 유효하지 않으면 기동 중단" 이다 — 호출자가 HIS 이므로
|
||||||
|
/// 조용히 읽기 전용으로 떨어뜨리면 사용자가 저장이 안 되는 이유를 알 수 없다.
|
||||||
|
/// </summary>
|
||||||
|
public static class SessionBootstrap
|
||||||
|
{
|
||||||
|
#region Methods
|
||||||
|
/// <summary>
|
||||||
|
/// 세션 초기화. 진행 가능하면 true, 기동을 중단해야 하면 false 와 함께 사유를 돌려준다.
|
||||||
|
///
|
||||||
|
/// 인자에 UidCod 있음 → 조회 성공 시 정상 세션 / 실패 시 기동 중단.
|
||||||
|
/// 인자 없음 → 개발 폴백(His:DevUidCod)을 시도하되 실패해도 미인증으로 계속(DB 쓰기만 차단).
|
||||||
|
/// 접속 문자열이 없으면(파일 전용 모드) 조회 자체를 건너뛴다.
|
||||||
|
/// </summary>
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>조회 실패(접속 오류 등)를 null 로 흡수 — 기동 경로에서 예외로 죽지 않게</summary>
|
||||||
|
private static HisUser? SafeResolve(string connectionString, string uidCod)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
return new UserContextStore(connectionString).Resolve(uidCod);
|
||||||
|
}
|
||||||
|
catch
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>HIS 기동 인자 — 레거시 규약 <c>"UidCod,ComNum,ShtCod"</c> (콤마 구분 단일 argv)</summary>
|
||||||
|
/// <param name="UidCod">HIS 사용자 코드 — 감사 컬럼의 출처</param>
|
||||||
|
/// <param name="ComNum">내원번호 — 레거시에서도 선언·대입만 있고 사용처가 없는 죽은 인자. 형상 보존용</param>
|
||||||
|
/// <param name="ShtCod">기동 시 자동으로 열 서식 코드</param>
|
||||||
|
/// <param name="Raw">원문 — 단일 인스턴스 포워딩에 그대로 전달</param>
|
||||||
|
public sealed record HisLaunch(string UidCod, int? ComNum, string ShtCod, string Raw);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 기동 인자 파싱. 레거시 frmSheetDesigner.vb:877 의 관용 파싱
|
||||||
|
/// (<c>(Command() & "".PadRight(10, ",")).Split(",")</c>)을 이식했다 —
|
||||||
|
/// 필드가 모자라도 인덱스가 보장되므로 인자 형상이 변해도 깨지지 않는다.
|
||||||
|
/// </summary>
|
||||||
|
public static class StartupArguments
|
||||||
|
{
|
||||||
|
#region Methods
|
||||||
|
/// <summary>진단 플래그 기동인지 — 첫 인자가 "--" 로 시작</summary>
|
||||||
|
public static bool IsDiagnostic(string[] args)
|
||||||
|
=> args.Length > 0 && args[0].StartsWith("--", StringComparison.Ordinal);
|
||||||
|
|
||||||
|
/// <summary>HIS 기동 인자 — 진단 모드이거나 인자가 없으면 null</summary>
|
||||||
|
public static HisLaunch? Parse(string[] args)
|
||||||
|
=> IsDiagnostic(args) || args.Length == 0 ? null : ParseRaw(string.Join(" ", args));
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 원문 파싱 — 단일 인스턴스 포워딩 수신부와 공용.
|
||||||
|
/// ComNum 은 레거시가 CInt 로 변환하다 비숫자에서 예외를 냈지만, 죽은 값 때문에 기동이
|
||||||
|
/// 실패하면 안 되므로 여기서는 TryParse 로 관용 처리한다.
|
||||||
|
/// </summary>
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using SheetMe.Data.Stores;
|
||||||
|
|
||||||
|
namespace SheetMe.Designer.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 프로세스 수명 사용자 세션 — 레거시 <c>clsCommonLib.GetInstance.UidMst</c> 싱글턴의 대응물.
|
||||||
|
///
|
||||||
|
/// 생성자 주입 대신 정적으로 둔 이유: MainViewModel 이 XAML DataContext 로 무인자 생성되고,
|
||||||
|
/// 무엇보다 진단 모드(--db-*)는 ViewModel 그래프 밖이라 주입으로는 감사 컬럼 기록 지점에
|
||||||
|
/// 도달할 수 없다. 대신 이 정적을 읽는 곳을 App 초기화 / FormDesignDataBusiness /
|
||||||
|
/// MainViewModel 표시 세 군데로 제한하고, 저장소는 지금처럼 uid 를 파라미터로만 받는다.
|
||||||
|
/// </summary>
|
||||||
|
public static class UserSession
|
||||||
|
{
|
||||||
|
#region Member Fields
|
||||||
|
/// <summary>진단 모드 감사 식별자 — DB 에서 즉시 구분되도록(개발자 윈도우 계정명 대신)</summary>
|
||||||
|
private const string DiagnosticUid = "SMOKE";
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Properties
|
||||||
|
/// <summary>현재 세션 사용자 — 초기화 전/미인증이면 <see cref="HisUser.Unknown"/></summary>
|
||||||
|
public static HisUser Current { get; private set; } = HisUser.Unknown;
|
||||||
|
|
||||||
|
/// <summary>HIS 사용자로 확인되었는지 — DB 쓰기의 전제</summary>
|
||||||
|
public static bool IsAuthenticated => Current.IsAuthenticated;
|
||||||
|
|
||||||
|
/// <summary>진단 모드 세션인지</summary>
|
||||||
|
public static bool IsDiagnostic { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 감사 컬럼에 기록할 사용자 코드. 미인증 상태에서 접근하면 예외 —
|
||||||
|
/// DB 쓰기는 그 전에 <see cref="IsAuthenticated"/> 로 차단되어야 한다.
|
||||||
|
/// </summary>
|
||||||
|
public static string AuditUid => IsAuthenticated
|
||||||
|
? Current.UidCod
|
||||||
|
: throw new InvalidOperationException("사용자 컨텍스트가 없어 DB 쓰기를 수행할 수 없습니다.");
|
||||||
|
#endregion
|
||||||
|
|
||||||
|
#region Methods
|
||||||
|
/// <summary>세션 확정 — 기동 시 1회</summary>
|
||||||
|
public static void Initialize(HisUser user)
|
||||||
|
{
|
||||||
|
Current = user;
|
||||||
|
IsDiagnostic = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>진단 모드 식별자 부여 — --db-* 스모크 전용</summary>
|
||||||
|
public static void InitializeDiagnostic()
|
||||||
|
{
|
||||||
|
Current = new HisUser(DiagnosticUid, "진단", string.Empty, string.Empty, string.Empty, string.Empty);
|
||||||
|
IsDiagnostic = true;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
}
|
||||||
@@ -285,14 +285,60 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
|
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
|
||||||
private DesignerViewModel? FindOpenDbDocument(string shtCod)
|
private DesignerViewModel? FindOpenDbDocument(string shtCod)
|
||||||
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
|
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
|
||||||
|
/// 거부 메시지는 레거시 문구를 그대로 재현한다(사용자 재교육 비용 0, 조치 안내가 이미 담겨 있다).
|
||||||
|
/// </summary>
|
||||||
|
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
|
#endregion
|
||||||
|
|
||||||
#region Methods - 열기/저장
|
#region Methods - 열기/저장
|
||||||
|
/// <summary>기동 인자로 받은 서식 코드 — 창 로드 후 자동으로 연다(App.OnStartup 이 세팅)</summary>
|
||||||
|
public string? PendingSheetCode { get; set; }
|
||||||
|
|
||||||
private async Task OnLoadedAsync()
|
private async Task OnLoadedAsync()
|
||||||
{
|
{
|
||||||
|
await LoadSheetListAsync();
|
||||||
|
|
||||||
|
// 기동 인자로 서식을 지정했으면 빈 새 문서를 만들지 않는다 — 레거시도 해당 서식만 연다.
|
||||||
|
// 열기에 실패하면(디자인 없음/권한 거부/코드 오타) 빈 문서로 폴백해 앱이 빈 껍데기가 되지 않게 한다.
|
||||||
|
if (PendingSheetCode is { Length: > 0 } shtCod)
|
||||||
|
{
|
||||||
|
PendingSheetCode = null;
|
||||||
|
OpenDbSheet(shtCod);
|
||||||
|
if (OpenDesigners.Count > 0)
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
OnNewFile();
|
OnNewFile();
|
||||||
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
|
SelectedLeftTabIndex = 0; // 시작 화면은 서식 목록 탭(초기 빈 문서로 레이어 탭 전환되는 것 되돌림)
|
||||||
await LoadSheetListAsync();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void OnNewFile()
|
private void OnNewFile()
|
||||||
@@ -404,7 +450,11 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화</summary>
|
/// <summary>
|
||||||
|
/// DB 서식 열기 공통 — 이미 열려 있으면 탭 활성화.
|
||||||
|
/// 모든 열기 경로(대화상자·목록 더블클릭·기동 인자)가 여기로 수렴하므로 권한 게이트를 여기 둔다.
|
||||||
|
/// 레거시는 게이트가 진입점마다 흩어져 있어 이력 노드 클릭·MessageQueue 두 경로로 우회가 가능했다.
|
||||||
|
/// </summary>
|
||||||
private void OpenDbSheet(string shtCod)
|
private void OpenDbSheet(string shtCod)
|
||||||
{
|
{
|
||||||
var existing = FindOpenDbDocument(shtCod);
|
var existing = FindOpenDbDocument(shtCod);
|
||||||
@@ -415,6 +465,11 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!EnsureDesignAllowed(shtCod))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var document = dataBusiness.OpenFromDb(shtCod);
|
var document = dataBusiness.OpenFromDb(shtCod);
|
||||||
if (document is null)
|
if (document is null)
|
||||||
{
|
{
|
||||||
@@ -444,10 +499,25 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
|
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (!dataBusiness.CanWriteDb)
|
||||||
|
{
|
||||||
|
MessageBox.Show("HIS 사용자가 확인되지 않아 DB에 저장할 수 없습니다.\n" +
|
||||||
|
"기록지정보 화면에서 서식생성기를 실행하거나, 사용자 코드를 인자로 전달해 주세요.\n" +
|
||||||
|
"(저장 이력에 남길 수정자를 특정할 수 없습니다)",
|
||||||
|
"DB 저장", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
CommitPendingEdits();
|
CommitPendingEdits();
|
||||||
|
|
||||||
var document = CurrentDesigner.Document;
|
var document = CurrentDesigner.Document;
|
||||||
|
|
||||||
|
// 열기 이후 마스터에서 플래그가 꺼졌을 수 있고, 파일→DB 저장은 열기 게이트를 아예 거치지 않는다.
|
||||||
|
// 레거시는 저장 경로에 게이트가 없어 이 구멍이 열려 있었다.
|
||||||
|
if (dataBusiness.SheetExists(document.FormId) && !EnsureDesignAllowed(document.FormId))
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
// 미등록 서식이면 신규 등록 다이얼로그
|
// 미등록 서식이면 신규 등록 다이얼로그
|
||||||
if (!dataBusiness.SheetExists(document.FormId))
|
if (!dataBusiness.SheetExists(document.FormId))
|
||||||
{
|
{
|
||||||
@@ -661,6 +731,13 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 파일에서 연 문서의 FormId 가 우연히 거부 서식 코드와 같으면 이 경로로 이력 열람·복원이
|
||||||
|
// 가능해진다(레거시의 우회 경로 중 하나였다).
|
||||||
|
if (!EnsureDesignAllowed(document.FormId))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
var versions = dataBusiness.ListVersions(document.FormId);
|
var versions = dataBusiness.ListVersions(document.FormId);
|
||||||
if (versions.Count == 0)
|
if (versions.Count == 0)
|
||||||
{
|
{
|
||||||
@@ -729,7 +806,8 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
return;
|
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,
|
Owner = Application.Current.MainWindow,
|
||||||
}.ShowDialog();
|
}.ShowDialog();
|
||||||
|
|||||||
@@ -278,6 +278,22 @@
|
|||||||
<StackPanel Orientation="Horizontal" Margin="1">
|
<StackPanel Orientation="Horizontal" Margin="1">
|
||||||
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
<TextBlock Text="{Binding ShtCod}" FontSize="11" Foreground="{DynamicResource B.Muted}"
|
||||||
Width="52" VerticalAlignment="Center"/>
|
Width="52" VerticalAlignment="Center"/>
|
||||||
|
<!-- 서식생성기 사용이 꺼진 서식(ShtUsrDesYon='N') — 열기 시 거부된다 -->
|
||||||
|
<TextBlock Text="" FontFamily="Segoe MDL2 Assets" FontSize="10"
|
||||||
|
VerticalAlignment="Center" Margin="0,0,4,0"
|
||||||
|
Foreground="{DynamicResource B.Muted}"
|
||||||
|
ToolTip="기록지 정보에서 서식생성기 사용이 꺼져 있습니다">
|
||||||
|
<TextBlock.Style>
|
||||||
|
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||||
|
<Setter Property="Visibility" Value="Collapsed"/>
|
||||||
|
<Style.Triggers>
|
||||||
|
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
|
||||||
|
<Setter Property="Visibility" Value="Visible"/>
|
||||||
|
</DataTrigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
</TextBlock.Style>
|
||||||
|
</TextBlock>
|
||||||
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
|
<TextBlock Text="{Binding Name}" FontSize="12" TextTrimming="CharacterEllipsis">
|
||||||
<TextBlock.Style>
|
<TextBlock.Style>
|
||||||
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
<Style TargetType="TextBlock" BasedOn="{StaticResource {x:Type TextBlock}}">
|
||||||
@@ -286,6 +302,10 @@
|
|||||||
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
<Setter Property="Opacity" Value="0.6"/>
|
<Setter Property="Opacity" Value="0.6"/>
|
||||||
</DataTrigger>
|
</DataTrigger>
|
||||||
|
<DataTrigger Binding="{Binding DesignAllowed}" Value="False">
|
||||||
|
<Setter Property="Foreground" Value="{DynamicResource B.Muted}"/>
|
||||||
|
<Setter Property="Opacity" Value="0.6"/>
|
||||||
|
</DataTrigger>
|
||||||
</Style.Triggers>
|
</Style.Triggers>
|
||||||
</Style>
|
</Style>
|
||||||
</TextBlock.Style>
|
</TextBlock.Style>
|
||||||
|
|||||||
@@ -13,23 +13,45 @@ public partial class RecordWordDialogView : Window
|
|||||||
#region Member Fields
|
#region Member Fields
|
||||||
private readonly RecordWordStore store;
|
private readonly RecordWordStore store;
|
||||||
private readonly string shtCod;
|
private readonly string shtCod;
|
||||||
|
private readonly string auditUid;
|
||||||
|
private readonly bool canWrite;
|
||||||
private readonly ObservableCollection<RecordWordInfo> words = new();
|
private readonly ObservableCollection<RecordWordInfo> words = new();
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
#region Constructors
|
#region Constructors
|
||||||
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle)
|
/// <param name="auditUid">감사 컬럼(ShtUpdUid/ShtFstUid)에 기록할 HIS 사용자 코드</param>
|
||||||
|
/// <param name="canWrite">DB 쓰기 가능 여부 — 미인증/게이트 차단이면 읽기 전용으로 연다</param>
|
||||||
|
public RecordWordDialogView(RecordWordStore store, string shtCod, string sheetTitle, string auditUid, bool canWrite)
|
||||||
{
|
{
|
||||||
this.store = store;
|
this.store = store;
|
||||||
this.shtCod = shtCod;
|
this.shtCod = shtCod;
|
||||||
|
this.auditUid = auditUid;
|
||||||
|
this.canWrite = canWrite;
|
||||||
InitializeComponent();
|
InitializeComponent();
|
||||||
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
|
HeaderText.Text = $"[{shtCod}] {sheetTitle} — 작성 화면의 상용구 팝업에 표시되는 문구입니다. " +
|
||||||
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)";
|
"(텍스트박스의 '상용구 사용'이 켜져 있어야 합니다)" +
|
||||||
|
(canWrite ? string.Empty : "\n※ HIS 사용자가 확인되지 않아 읽기 전용입니다.");
|
||||||
WordList.ItemsSource = words;
|
WordList.ItemsSource = words;
|
||||||
Loaded += (_, _) => Reload();
|
Loaded += (_, _) => Reload();
|
||||||
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
|
NewWordBox.Loaded += (_, _) => NewWordBox.Focus();
|
||||||
}
|
}
|
||||||
#endregion
|
#endregion
|
||||||
|
|
||||||
|
#region Methods - 가드
|
||||||
|
/// <summary>쓰기 전 확인 — 미인증이면 안내 후 차단(감사 컬럼에 남길 주체가 없다)</summary>
|
||||||
|
private bool EnsureWritable()
|
||||||
|
{
|
||||||
|
if (canWrite)
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
MessageBox.Show("HIS 사용자가 확인되지 않아 상용구를 변경할 수 없습니다.\n" +
|
||||||
|
"기록지정보 화면에서 서식생성기를 실행해 주세요.",
|
||||||
|
"상용구 관리", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
#endregion
|
||||||
|
|
||||||
#region Methods
|
#region Methods
|
||||||
private void Reload()
|
private void Reload()
|
||||||
{
|
{
|
||||||
@@ -51,13 +73,13 @@ public partial class RecordWordDialogView : Window
|
|||||||
private void OnAdd(object sender, RoutedEventArgs e)
|
private void OnAdd(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
var value = NewWordBox.Text.Trim();
|
var value = NewWordBox.Text.Trim();
|
||||||
if (value.Length == 0)
|
if (value.Length == 0 || !EnsureWritable())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
store.Add(shtCod, value, Environment.UserName);
|
store.Add(shtCod, value, auditUid);
|
||||||
NewWordBox.Clear();
|
NewWordBox.Clear();
|
||||||
NewWordBox.Focus();
|
NewWordBox.Focus();
|
||||||
Reload();
|
Reload();
|
||||||
@@ -72,7 +94,7 @@ public partial class RecordWordDialogView : Window
|
|||||||
|
|
||||||
private void OnEdit(object sender, RoutedEventArgs e)
|
private void OnEdit(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,7 +111,7 @@ public partial class RecordWordDialogView : Window
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
store.Update(selected.Key, newValue, Environment.UserName);
|
store.Update(selected.Key, newValue, auditUid);
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -101,7 +123,7 @@ public partial class RecordWordDialogView : Window
|
|||||||
|
|
||||||
private void OnRemove(object sender, RoutedEventArgs e)
|
private void OnRemove(object sender, RoutedEventArgs e)
|
||||||
{
|
{
|
||||||
if (WordList.SelectedItem is not RecordWordInfo selected)
|
if (WordList.SelectedItem is not RecordWordInfo selected || !EnsureWritable())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -112,7 +134,7 @@ public partial class RecordWordDialogView : Window
|
|||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
store.Remove(selected.Key);
|
store.Remove(selected.Key, auditUid);
|
||||||
Reload();
|
Reload();
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
@@ -130,14 +152,14 @@ public partial class RecordWordDialogView : Window
|
|||||||
{
|
{
|
||||||
var index = WordList.SelectedIndex;
|
var index = WordList.SelectedIndex;
|
||||||
var target = index + delta;
|
var target = index + delta;
|
||||||
if (index < 0 || target < 0 || target >= words.Count)
|
if (index < 0 || target < 0 || target >= words.Count || !EnsureWritable())
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
words.Move(index, target);
|
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;
|
WordList.SelectedIndex = target;
|
||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
|
|||||||
Reference in New Issue
Block a user