같은 서식 중복 열기 차단 — 출처를 가리지 않는다 (+ 3단계 판단용 실측 진단)
■ 중복 열기로 편집분이 통째로 사라지던 문제
중복 차단이 d.IsFromDb 를 요구했다. 그래서 XML 파일로 이미 열어 둔 서식을 목록에서
더블클릭해 DB 로 또 열 수 있었고, 두 탭에서 교대로 저장하면 낙관적 충돌 검출이
양쪽 어디에도 없어 마지막 저장이 앞선 편집을 통째로 덮었다.
레거시 MultiSheetFormAllow 는 탭 제목의 [서식코드]만 보고 출처와 무관하게 막는다
(frmSheetDesigner.vb:242-252). 같은 규칙으로 맞췄다:
· 판정을 FindOpenSheet(서식코드) 하나로 모으고 IsFromDb 조건을 뺐다.
· 열기 경로 3곳(DB / XML 파일 / JSON 가져오기)이 전부 이 지점을 지난다.
종전에는 DB 경로에만 검사가 있었다.
· 이력 탭은 계속 제외한다 — 현재본을 열려는 요청이 과거 스냅샷을 활성화하면 안 된다.
· 빈 서식코드는 서로 다른 문서로 본다. 묶으면 '새 서식'을 두 번 만들 수 없다.
· 막을 때 어느 탭으로 갔는지 상태줄에 적는다. 아무 일도 안 일어난 것처럼 보이면 사용자는 다시 누른다.
비교용으로 나란히 열어야 하는 경우를 위해 탈출구를 남겼다 —
DataConfig.AllowMultiSheetForm(기본 false, 레거시 AllowMultiSheetForm 기본 "N" 과 동일).
■ 함께 고친 것 — 이력본을 저장해도 탭이 '이력'으로 남던 문제
DB 저장 후 HistorySdgKey 를 null 로 되돌리지 않아 탭 제목이 '(이력 N)' 으로 남고,
종료 확인이 '저장할 수 없는 탭' 분기로 들어가 저장 선택지 없이
'변경을 버리고 종료할까요?' 만 물었다 — 저장 뒤의 편집분이 조용히 버려진다.
저장한 순간 그 탭은 스냅샷이 아니라 활성 디자인이므로 표식을 지운다.
이 수정이 만드는 경계가 하나 있다: 이력 탭을 되살려 저장하면 같은 서식의 다른 탭이
이제 옛 내용을 들고 있게 된다. 그 탭에서 저장하면 방금 저장분을 덮으므로,
어느 탭이 낡았는지 알리는 경고를 띄운다(자동으로 닫지는 않는다 — 미저장 편집분이 있을 수 있다).
■ 3단계 판단용 실측(--db-phase3)
사이트 확인을 기다리는 동안 DB 로 알 수 있는 것부터 쟀다:
· ShtCneYon='Y' 40건, 그중 현역 19건 — TK_MODIFY(제자리 정정) 부재의 실제 영향 범위.
· ShtDepCls 는 418건(24%)에 실제 분류가 들어 있다(산업재해·물리치료실·병동출력물…).
비어 있지 않으므로 진료과 2단계 그룹 미이식은 목록 구성을 실제로 바꾼다.
· ShtDspSeq 는 1,714건(98.7%)이 1 이상이다. 표시 순번은 사실상 전 서식에 채워져 있어,
정렬 미이식은 거의 모든 서식의 목록 순서를 레거시와 다르게 만든다.
· ShtTyp='S'(스캔형) 108건, 현역 105건. 사이트 조건 이식이 걸리는 모집단이 실재한다.
· EMRShtSortTyp 는 확인하지 못했다 — 코드 마스터(DtlCod/DtlCodVal) 테이블이
이 접속 스키마에 없다. 운영 접속에서 재측정이 필요하다.
편집 스모크 5건 추가(출처 무관 판정 / 대소문자 / 이력 제외 / 무관 서식 / 빈 코드).
회귀: 테스트 142/142, 편집 스모크 192건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
a90dd1301e
commit
ddf2bdaee8
@@ -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]);
|
||||
|
||||
@@ -615,6 +615,93 @@ public static class DbSmoke
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 3단계 판단용 실측(--db-phase3) — 목록·정렬 관련 컬럼이 실제로 채워져 있는지(읽기 전용).
|
||||
///
|
||||
/// 레거시에는 있는데 우리에게 없는 기능 중 어느 것이 실제로 업무를 막는지는
|
||||
/// 코드만 봐서는 알 수 없다. 컬럼이 비어 있으면 그 기능은 이 병원에서 안 쓰던 것이고,
|
||||
/// 채워져 있으면 이식하지 않는 순간 목록이 달라진다.
|
||||
/// </summary>
|
||||
public static int RunPhase3Report(string reportPath)
|
||||
{
|
||||
var lines = new List<string>();
|
||||
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<string>();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DataTableField / DataTableItms 값 형태 분포(--db-dtf) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용).
|
||||
///
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -374,14 +374,48 @@ internal sealed class MainViewModel : ViewModelBase
|
||||
public bool IsShuttingDown { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null.
|
||||
/// 이미 열려 있는 같은 서식 — 없으면 null.
|
||||
///
|
||||
/// <b>출처(파일/DB)를 보지 않는다.</b> 레거시 MultiSheetFormAllow 도 탭 제목의 [서식코드]만 보고
|
||||
/// 출처와 무관하게 막는다(frmSheetDesigner.vb:242-252). 출처를 따지면 XML 파일로 열어 둔 서식을
|
||||
/// 목록에서 다시 DB 로 열 수 있고, 두 탭에서 교대로 저장하면 <b>마지막 저장이 앞선 편집을 통째로 덮는다</b>
|
||||
/// — 양쪽 어디에도 낙관적 충돌 검출이 없다.
|
||||
///
|
||||
/// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서
|
||||
/// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을
|
||||
/// 편집한다고 믿은 채 스냅샷을 고치게 된다.
|
||||
///
|
||||
/// 서식 코드가 비어 있으면(새 서식) 서로 다른 문서로 본다 — 빈 코드끼리 묶으면
|
||||
/// '새 서식'을 두 번 만들 수 없다.
|
||||
/// </summary>
|
||||
private DesignerViewModel? FindOpenDbDocument(string shtCod)
|
||||
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.HistorySdgKey is null && d.Document.FormId == shtCod);
|
||||
internal static DesignerViewModel? FindOpenSheet(IEnumerable<DesignerViewModel> open, string formId)
|
||||
=> formId.Length == 0
|
||||
? null
|
||||
: open.FirstOrDefault(d =>
|
||||
d.HistorySdgKey is null
|
||||
&& string.Equals(d.Document.FormId, formId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>
|
||||
/// 이미 열려 있으면 그 탭을 활성화하고 true — 새로 열지 말라는 뜻.
|
||||
/// 설정으로 중복 열기를 허용하면 항상 false(레거시 AllowMultiSheetForm 과 같은 탈출구).
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 서식생성기 사용 권한 확인(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
|
||||
/// </summary>
|
||||
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))}";
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user