전수조사 1단계 — 저장된 서식을 오염시키는 결함 7건 수정

레거시 대비 전수조사에서 나온 출시 차단 9건 중, 하루 안에 끝나는 국소 수정 7건.
저장 데이터를 망가뜨리는 ③④ 를 먼저 고쳤다.

③ 페이지 복제·추가가 루트 이름을 중복 발급했다
   Clone 이 Root.Id 까지 베끼는데 자식만 재발급해서 한 서식에 MDesignerHost1 이 둘 생겼다.
   AddPage 도 '페이지 수 + 1' 로 지어서 루트가 1·3 인 2장짜리 서식에서 곧바로 3 이 겹쳤다.
   E_SctMst 동기화는 SctObjNam 사전이라 둘째 루트가 첫째 루트 행에 매핑되고,
   그 페이지 자식들의 SctParObj 가 없는 ObjID 를 가리킨 채 남는다.
   → 두 경로 모두 RenamePageRoot 로 이름과 Name 속성을 함께 새로 받는다.

④ 이름 채번이 빈 번호를 메웠다
   TextBox3 을 지우고 새로 놓으면 다시 TextBox3 이 나왔다. 이름은 저장된 답변
   (E_SctDta.SctObjNam)이 붙는 열쇠라서, 지운 칸의 과거 입력이 새 칸에 그대로 나타난다.
   액션 대상과 CalcBox 수식도 이름으로 서로를 가리키므로 조용히 오배선된다.
   → 레거시 clsNameCreationService 규약대로 '사용 중 최대번호 + 1'.
   숫자가 아닌 접미사(TextBoxTotal)는 번호로 보지 않는다.

① 탭순서 적용이 문서 전체를 밀었다
   한 페이지를 편집해도 전 페이지 컨트롤이 999999+i 로 초기화됐다.
   레거시는 페이지마다 별도 디자인 서피스라 다른 페이지는 건드리지 않는다.
   → 초기화 범위를 이번에 클릭한 페이지로 한정.

② 이력본이 열린 상태에서 현재본을 열면 이력 탭이 활성화됐다
   FindOpenDbDocument 가 ShtCod 만 보고 있었다. 사용자는 현재본을 고친다고 믿은 채
   읽기 전용 스냅샷을 편집하게 된다. → HistorySdgKey is null 조건 추가.

⑤ 레거시에 없는 태그 3종을 고를 수 있었다
   PAT_BMI · PAT_IBW · PAT_IBW_퍼센트 는 bzDataInterface.vb:922/957/970 에서
   주석 처리된 채로 남아 있어 런타임에 해석되지 않는다. 고르면 배포 후에야 빈칸으로 드러난다.
   → 카탈로그에서 제거(값은 자유 입력이라 기존 서식에는 영향 없다).

⑥ 신규 서식 등록이 ShtHspYon·ShtExpDte 를 비웠다
   --db-shtmst 진단을 신설해 실측한 결과 운영 E_ShtMst 1,737행 중 두 컬럼이
   NULL 인 행은 0건이다. ShtExpDte 는 dtEMRLoader.vb:1229 가
   NVL(ShtExpDte,' ')='29991231' 로 못박아 거르고, 여러 조회가
   sysdate BETWEEN ShtAdpDte AND ShtExpDte 를 쓴다 — NULL 이면 BETWEEN 이 거짓이라
   등록은 성공했는데 EMR 어디에서도 안 보이는 서식이 된다(29991231 이 1,718행 98.9%).
   ShtHspYon 은 기록지정보·자동생성 목록이 'Y' 로 거르며, 디자이너가 만든 서식
   169건 중 160건이 'Y'. → 두 값을 INSERT 에 넣는다.

⑦ 빈 선택 상태의 Ctrl+드래그가 예외로 죽었다
   Ctrl/Shift 클릭은 선택을 바꾸지 않고 업에서 토글하므로 주선택이 빈 채 드래그가
   시작될 수 있는데, BeginMove 가 Selection.Primary! 를 그대로 넘겨
   RootAncestorOf 가 즉시 역참조했다. → 앵커를 Primary ?? 첫 선택으로 완화.

편집 스모크 9건 추가(루트 이름 유일 2 · 채번 4 · 탭순서 페이지 격리 1 · 빈 선택 드래그 1 · 태그 3종 부재 1).
채번 검사는 '가운데 번호'를 지운다 — 최대 번호를 지우면 최대값 자체가 내려가므로
같은 이름이 다시 나오는 것이 레거시와 같은 정상 동작이다.

회귀: 테스트 124/124, 편집 스모크 169건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일(HEAD 대비 md5 일치).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 12:08:06 +09:00
co-authored by Claude Opus 5
parent fdb1476b0b
commit 8eeff42a72
9 changed files with 248 additions and 18 deletions
@@ -400,15 +400,39 @@ public sealed class DesignerViewModel : ViewModelBase
RebuildLayerRows();
}
/// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary>
/// <summary>
/// 페이지 추가 — 문서 끝에 빈 페이지(720×856).
///
/// 루트 이름은 페이지 수가 아니라 <b>사용 중인 이름</b>을 보고 정한다.
/// 페이지 수로 지으면 루트가 MDesignerHost1·MDesignerHost3 인 2장짜리 서식에서
/// 곧바로 MDesignerHost3 이 중복된다(레거시는 전 페이지를 훑어 최대번호+1 을 붙인다).
/// </summary>
public void AddPage()
{
Undo.Snapshot();
document.Pages.Add(LegacyXmlSerializer.CreateEmptyPage(document.Pages.Count + 1));
var page = LegacyXmlSerializer.CreateEmptyPage(document.Pages.Count + 1);
RenamePageRoot(page, IdGenerator.CollectUsed(document));
document.Pages.Add(page);
RebuildFromDocument();
SelectedPage = Pages.LastOrDefault();
}
/// <summary>
/// 페이지 루트에 유일한 이름을 부여한다 — 이름과 Name 속성을 함께 맞춘다.
///
/// 루트 이름이 겹치면 제자리 갱신 저장이 깨진다: E_SctMst 동기화는 SctObjNam 사전이라
/// 둘째 루트가 첫째 루트 행에 매핑되고, 그 페이지 자식들의 SctParObj 가 존재하지 않는
/// ObjID 를 가리킨 채 남는다(화면은 뜨지만 항목 계층이 깨진다).
/// </summary>
private static void RenamePageRoot(FormPage page, HashSet<string> usedIds)
{
page.Root.Id = IdGenerator.NextId(usedIds, LegacyTypeCatalog.HostClassName);
if (page.Root.Props.Contains("Name"))
{
page.Root.Props.SetText("Name", page.Root.Id);
}
}
/// <summary>
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
///
@@ -428,6 +452,9 @@ public sealed class DesignerViewModel : ViewModelBase
var clone = target.Model.Clone();
var usedIds = IdGenerator.CollectUsed(document);
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
// 페이지 루트도 이름을 새로 받아야 한다 — Clone 이 Root.Id 까지 복사하므로
// 자식만 재발급하면 한 서식에 같은 이름의 루트가 둘 생긴다
RenamePageRoot(clone, usedIds);
foreach (var element in clone.Controls)
{
AssignNewIds(element, usedIds);
@@ -863,7 +890,15 @@ public sealed class DesignerViewModel : ViewModelBase
Overlay.TabBadges.Clear();
}
/// <summary>탭순서 적용 — 전 컨트롤 999999+i 초기화 후 클릭 순서 0,100,200…(레거시 TK_TABINDEXSAVE 이식)</summary>
/// <summary>
/// 탭순서 적용 — 초기화(999999+i) 후 클릭 순서 0,100,200…(레거시 TK_TABINDEXSAVE 이식).
///
/// 초기화 범위는 <b>이번에 손댄 페이지</b>로 한정한다. 레거시는 페이지마다 별도 디자인
/// 서피스라 한 페이지를 편집해도 다른 페이지 TabIndex 는 그대로 남는데, 문서 전체를 밀면
/// 손대지 않은 페이지의 탭 순서가 통째로 999999+i 로 덮여 입력 순서가 무너진다.
/// 여러 페이지를 걸쳐 클릭했다면 그 페이지들이 모두 대상이 된다(한 페이지만 클릭한
/// 보통의 경우는 레거시와 동일하게 동작한다).
/// </summary>
private void ApplyTabOrder()
{
if (tabOrderSequence.Count == 0)
@@ -872,8 +907,16 @@ public sealed class DesignerViewModel : ViewModelBase
}
Undo.Snapshot();
var touched = new HashSet<PageViewModel>();
foreach (var vm in tabOrderSequence)
{
if (PageOf(vm) is { } page)
{
touched.Add(page);
}
}
var index = 0;
foreach (var vm in AllControls())
foreach (var vm in AllControls().Where(v => PageOf(v) is { } p && touched.Contains(p)))
{
index++;
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
@@ -373,9 +373,15 @@ internal sealed class MainViewModel : ViewModelBase
/// <summary>종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록</summary>
public bool IsShuttingDown { get; private set; }
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
/// <summary>
/// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null.
///
/// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서
/// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을
/// 편집한다고 믿은 채 스냅샷을 고치게 된다.
/// </summary>
private DesignerViewModel? FindOpenDbDocument(string shtCod)
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.Document.FormId == shtCod);
=> OpenDesigners.FirstOrDefault(d => d.IsFromDb && d.HistorySdgKey is null && d.Document.FormId == shtCod);
/// <summary>
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.