diff --git a/src/SheetMe.Data/Config/DataConfig.cs b/src/SheetMe.Data/Config/DataConfig.cs index ad2db69..43e06de 100644 --- a/src/SheetMe.Data/Config/DataConfig.cs +++ b/src/SheetMe.Data/Config/DataConfig.cs @@ -28,6 +28,15 @@ public sealed class DataConfig /// 파일 모드 서식 폴더 public string XmlFolder { get; set; } = "forms"; + /// + /// 같은 서식을 여러 탭으로 여는 것을 허용할지 — 기본 false(레거시 AllowMultiSheetForm 기본 "N" 과 동일). + /// + /// 막는 이유는 불편이 아니라 데이터 손실이다. 두 탭에서 교대로 저장하면 낙관적 충돌 검출이 + /// 어느 쪽에도 없어 마지막 저장이 앞선 편집을 통째로 덮는다. + /// 비교용으로 나란히 열어야 하는 경우가 있어 탈출구는 남겨 둔다. + /// + public bool AllowMultiSheetForm { get; set; } + /// /// 개발용 HIS 사용자 코드 — 기동 인자 없이 실행할 때만 시도하는 폴백. /// 운영 배포본의 appsettings.json 에는 이 키가 없으므로 자동으로 비활성이다. diff --git a/src/SheetMe.Designer/App.xaml.cs b/src/SheetMe.Designer/App.xaml.cs index 679553f..618eaae 100644 --- a/src/SheetMe.Designer/App.xaml.cs +++ b/src/SheetMe.Designer/App.xaml.cs @@ -142,6 +142,11 @@ public partial class App : Application return Diagnostics.DbSmoke.RunRow(args[1], args[2]); } + if (args.Length >= 2 && args[0] == "--db-phase3") + { + return Diagnostics.DbSmoke.RunPhase3Report(args[1]); + } + if (args.Length >= 2 && args[0] == "--db-dtf") { return Diagnostics.DbSmoke.RunDataTableFieldReport(args[1]); diff --git a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs index 87d17a6..24ed99c 100644 --- a/src/SheetMe.Designer/Diagnostics/DbSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/DbSmoke.cs @@ -615,6 +615,93 @@ public static class DbSmoke } } + /// + /// 3단계 판단용 실측(--db-phase3) — 목록·정렬 관련 컬럼이 실제로 채워져 있는지(읽기 전용). + /// + /// 레거시에는 있는데 우리에게 없는 기능 중 어느 것이 실제로 업무를 막는지는 + /// 코드만 봐서는 알 수 없다. 컬럼이 비어 있으면 그 기능은 이 병원에서 안 쓰던 것이고, + /// 채워져 있으면 이식하지 않는 순간 목록이 달라진다. + /// + public static int RunPhase3Report(string reportPath) + { + var lines = new List(); + try + { + var config = ConfigService.Current; + using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); + connection.Open(); + + void Distribution(string title, string sql) + { + lines.Add(string.Empty); + lines.Add(title); + try + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + using var reader = command.ExecuteReader(); + var any = false; + var rank = 0; + while (reader.Read() && rank++ < 12) + { + any = true; + var parts = new List(); + for (var i = 0; i < reader.FieldCount; i++) + { + parts.Add(reader.IsDBNull(i) ? "(null)" : reader.GetValue(i).ToString() ?? string.Empty); + } + lines.Add(" " + string.Join(" ", parts)); + } + if (!any) + { + lines.Add(" (행 없음)"); + } + } + catch (Exception ex) + { + lines.Add(" 조회 실패: " + ex.Message); + } + } + + // ① TK_MODIFY(제자리 정정) 대체 필요성 — ShtCneYon='Y' 서식이 얼마나 되는가 + Distribution("[ShtCneYon] 제자리 갱신 서식 수 — 'Y' 가 많으면 TK_MODIFY 부재가 곧 업무 차단이다", + "SELECT NVL(ShtCneYon,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtCneYon,'(null)') ORDER BY COUNT(*) DESC"); + Distribution("[ShtCneYon='Y' 중 사용 중] 실제로 현역인 것만", + "SELECT COUNT(*) FROM E_ShtMst WHERE ShtCneYon = 'Y' AND ShtUseYon = 'Y' AND NVL(ShtHspYon,' ') = 'Y'"); + + // ② 서식 목록의 진료과 2단계 그룹 — ShtDepCls 가 채워져 있는가 + Distribution("[ShtDepCls] 진료과 분류 — 비어 있으면 2단계 그룹 이식은 무의미하다", + "SELECT NVL(ShtDepCls,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtDepCls,'(null)') ORDER BY COUNT(*) DESC"); + + // ③ 병원별 정렬 설정 — EMRShtSortTyp / ShtDspSeq + Distribution("[ShtDspSeq] 표시 순번 — 0/null 뿐이면 정렬 설정은 안 쓰이는 것이다", + "SELECT CASE WHEN ShtDspSeq IS NULL THEN '(null)' WHEN ShtDspSeq = 0 THEN '0' ELSE '1 이상' END v, " + + "COUNT(*) c FROM E_ShtMst GROUP BY CASE WHEN ShtDspSeq IS NULL THEN '(null)' WHEN ShtDspSeq = 0 THEN '0' ELSE '1 이상' END " + + "ORDER BY COUNT(*) DESC"); + Distribution("[EMRShtSortTyp] 병원 환경설정의 정렬 방식", + "SELECT EnvCod, EnvVal FROM M_EnvMst WHERE UPPER(EnvCod) LIKE '%SHTSORT%' OR UPPER(EnvCod) LIKE '%EMRSHT%'"); + + // ④ 스캔형 동의서 — 사이트 조건이 걸리는 대상이 실제로 있는가 + Distribution("[ShtTyp='S'] 스캔형 서식 수 — 사이트 조건 이식이 필요한 모집단", + "SELECT NVL(ShtTyp,'(null)') v, COUNT(*) c FROM E_ShtMst GROUP BY NVL(ShtTyp,'(null)') ORDER BY COUNT(*) DESC"); + Distribution("[스캔형 중 현역]", + "SELECT COUNT(*) FROM E_ShtMst WHERE ShtTyp = 'S' AND ShtUseYon = 'Y' AND NVL(ShtHspYon,' ') = 'Y'"); + + // ⑤ 이 DB 가 어느 병원인지 — 사이트 조건 판단의 출발점 + Distribution("[병원 식별] M_EnvMst 의 병원명/코드 계열 설정", + "SELECT EnvCod, EnvVal FROM M_EnvMst WHERE UPPER(EnvCod) LIKE '%HSP%' OR UPPER(EnvCod) LIKE '%HOSP%'"); + + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 0; + } + catch (Exception ex) + { + lines.Add("실패: " + ex.Message); + File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); + return 1; + } + } + /// /// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용). /// diff --git a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs index 1676dcf..82c18da 100644 --- a/src/SheetMe.Designer/Diagnostics/EditSmoke.cs +++ b/src/SheetMe.Designer/Diagnostics/EditSmoke.cs @@ -857,6 +857,35 @@ public static class EditSmoke designer.Selection.Clear(); } + // 20-5h) 같은 서식 중복 열기 판정 — 출처(파일/DB)를 보지 않는다. + // 출처를 따지면 XML 로 열어 둔 서식을 DB 로 또 열 수 있고, 두 탭에서 교대로 저장하면 + // 낙관적 충돌 검출이 어디에도 없어 마지막 저장이 앞선 편집을 통째로 덮는다. + { + ViewModels.DesignerViewModel Tab(string formId, decimal? history = null, bool fromDb = false) + { + var doc = new Core.Models.FormDocument { FormId = formId }; + doc.Pages.Add(Core.Serialization.LegacyXmlSerializer.CreateEmptyPage(1)); + return new ViewModels.DesignerViewModel(doc) { IsFromDb = fromDb, HistorySdgKey = history }; + } + + var fileTab = Tab("S001"); + var otherTab = Tab("S002", fromDb: true); + var historyTab = Tab("S003", history: 42m); + var open = new[] { fileTab, otherTab, historyTab }; + + Check("중복 열기: 파일 탭을 DB 열기가 찾아낸다", + ReferenceEquals(ViewModels.MainViewModel.FindOpenSheet(open, "S001"), fileTab)); + Check("중복 열기: 대소문자를 가리지 않는다", + ReferenceEquals(ViewModels.MainViewModel.FindOpenSheet(open, "s001"), fileTab)); + Check("중복 열기: 이력 탭은 현재본으로 치지 않는다", + ViewModels.MainViewModel.FindOpenSheet(open, "S003") is null); + Check("중복 열기: 다른 서식은 걸리지 않는다", + ViewModels.MainViewModel.FindOpenSheet(open, "S999") is null); + // 빈 코드끼리 묶으면 '새 서식'을 두 번 만들 수 없다 + Check("중복 열기: 빈 서식코드는 서로 다른 문서로 본다", + ViewModels.MainViewModel.FindOpenSheet(new[] { Tab(string.Empty) }, string.Empty) is null); + } + // 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다 Check("태그 카탈로그: 미구현 3종 제외", !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI") diff --git a/src/SheetMe.Designer/ViewModels/MainViewModel.cs b/src/SheetMe.Designer/ViewModels/MainViewModel.cs index da6b756..ec70ab7 100644 --- a/src/SheetMe.Designer/ViewModels/MainViewModel.cs +++ b/src/SheetMe.Designer/ViewModels/MainViewModel.cs @@ -374,14 +374,48 @@ internal sealed class MainViewModel : ViewModelBase public bool IsShuttingDown { get; private set; } /// - /// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null. + /// 이미 열려 있는 같은 서식 — 없으면 null. + /// + /// 출처(파일/DB)를 보지 않는다. 레거시 MultiSheetFormAllow 도 탭 제목의 [서식코드]만 보고 + /// 출처와 무관하게 막는다(frmSheetDesigner.vb:242-252). 출처를 따지면 XML 파일로 열어 둔 서식을 + /// 목록에서 다시 DB 로 열 수 있고, 두 탭에서 교대로 저장하면 마지막 저장이 앞선 편집을 통째로 덮는다 + /// — 양쪽 어디에도 낙관적 충돌 검출이 없다. /// /// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서 /// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을 /// 편집한다고 믿은 채 스냅샷을 고치게 된다. + /// + /// 서식 코드가 비어 있으면(새 서식) 서로 다른 문서로 본다 — 빈 코드끼리 묶으면 + /// '새 서식'을 두 번 만들 수 없다. /// - private DesignerViewModel? FindOpenDbDocument(string shtCod) - => OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.HistorySdgKey is null && d.Document.FormId == shtCod); + internal static DesignerViewModel? FindOpenSheet(IEnumerable open, string formId) + => formId.Length == 0 + ? null + : open.FirstOrDefault(d => + d.HistorySdgKey is null + && string.Equals(d.Document.FormId, formId, StringComparison.OrdinalIgnoreCase)); + + /// + /// 이미 열려 있으면 그 탭을 활성화하고 true — 새로 열지 말라는 뜻. + /// 설정으로 중복 열기를 허용하면 항상 false(레거시 AllowMultiSheetForm 과 같은 탈출구). + /// + private bool ActivateIfAlreadyOpen(string formId, string source) + { + if (dataBusiness.Config.AllowMultiSheetForm) + { + return false; + } + var existing = FindOpenSheet(OpenDesigners, formId); + if (existing is null) + { + return false; + } + CurrentDesigner = existing; + // 어느 탭으로 갔는지 알려 준다 — 아무 일도 안 일어난 것처럼 보이면 사용자는 다시 누른다 + var where = existing.IsFromDb ? "DB 탭" : "파일 탭"; + StatusText = $"이미 열린 서식: {formId} — {where}으로 이동했습니다({source} 열기는 취소)."; + return true; + } /// /// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식. @@ -461,6 +495,11 @@ internal sealed class MainViewModel : ViewModelBase return; } var document = dataBusiness.OpenXmlFile(path); + // 같은 서식이 DB 탭으로 이미 열려 있을 수 있다 — 출처가 달라도 저장 대상은 같은 서식이다 + if (ActivateIfAlreadyOpen(document.FormId, "파일")) + { + return; + } AttachDocument(new DesignerViewModel(document) { FilePath = path }); StatusText = $"파일 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}"; ShowReadWarnings(document.Meta.ReadWarnings, "서식 열기"); @@ -551,11 +590,8 @@ internal sealed class MainViewModel : ViewModelBase /// private void OpenDbSheet(string shtCod) { - var existing = FindOpenDbDocument(shtCod); - if (existing is not null) + if (ActivateIfAlreadyOpen(shtCod, "DB")) { - CurrentDesigner = existing; - StatusText = $"이미 열린 서식: {shtCod}"; return; } @@ -644,8 +680,23 @@ internal sealed class MainViewModel : ViewModelBase var sdgKey = dataBusiness.SaveToDb(document); Services.AppLog.Audit($"[DB저장] {document.FormId} → SdgKey {sdgKey} (by {dataBusiness.User.Display})"); CurrentDesigner.IsFromDb = true; + // 이력 표식을 지운다 — 저장한 순간 이 탭은 과거 스냅샷이 아니라 활성 디자인이다. + // 남겨 두면 탭이 '(이력 N)' 으로 계속 보이고, 종료 확인이 '저장할 수 없는 탭' 분기로 들어가 + // 저장 선택지 없이 '변경을 버리고 종료할까요?' 만 물어 이후 편집분이 조용히 버려진다. + var wasHistory = CurrentDesigner.HistorySdgKey is not null; + CurrentDesigner.HistorySdgKey = null; CurrentDesigner.Undo.MarkSaved(); CurrentDesigner.NotifyDisplayNameChanged(); + // 이력 탭을 되살린 경우, 같은 서식의 다른 탭은 이제 옛 내용을 들고 있다. + // 그 탭에서 저장하면 방금 저장한 내용을 덮으므로 어느 탭이 낡았는지 알려 준다. + if (wasHistory && OpenDesigners.Any(d => !ReferenceEquals(d, CurrentDesigner) + && d.HistorySdgKey is null + && string.Equals(d.Document.FormId, document.FormId, StringComparison.OrdinalIgnoreCase))) + { + DialogService.Notify(DialogKind.Warning, "같은 서식의 다른 탭", + $"서식 [{document.FormId}] 이(가) 다른 탭에도 열려 있습니다.", + "그 탭은 방금 저장하기 전의 내용입니다. 거기서 저장하면 이번 저장이 덮어써집니다 — 닫고 다시 여세요."); + } StatusText = $"DB 저장 완료: {document.FormId} → SdgKey {sdgKey}"; DialogService.Notify(DialogKind.Info, "DB 저장", $"저장되었습니다. (SdgKey {sdgKey})", "레거시 뷰어/디자이너에서 열어 확인하세요."); @@ -697,6 +748,10 @@ internal sealed class MainViewModel : ViewModelBase { document.FormId = Path.GetFileNameWithoutExtension(path); } + if (ActivateIfAlreadyOpen(document.FormId, "JSON")) + { + return; + } AttachDocument(new DesignerViewModel(document)); StatusText = $"JSON 로드: 페이지 {document.Pages.Count} · 컨트롤 {document.Pages.Sum(p => CountControls(p.Controls))}"; }