전수조사 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:
co-authored by
Claude Opus 5
parent
fdb1476b0b
commit
8eeff42a72
@@ -508,9 +508,9 @@ public static class LegacyTagCatalog
|
|||||||
"OCM_협진과_협진의",
|
"OCM_협진과_협진의",
|
||||||
"OCM_호흡",
|
"OCM_호흡",
|
||||||
"OCM_호흡_LAST",
|
"OCM_호흡_LAST",
|
||||||
"PAT_BMI",
|
// PAT_BMI · PAT_IBW · PAT_IBW_퍼센트 는 넣지 않는다 —
|
||||||
"PAT_IBW",
|
// bzDataInterface.vb:922/957/970 에서 주석 처리된 채로 남아 있어 런타임에 해석되지 않는다.
|
||||||
"PAT_IBW_퍼센트",
|
// 목록에 두면 설계자가 고를 수 있고, 그 칸은 서식이 배포된 뒤에야 빈칸으로 드러난다.
|
||||||
"PAT_건보_급여_세대주명",
|
"PAT_건보_급여_세대주명",
|
||||||
"PAT_건보세대주명",
|
"PAT_건보세대주명",
|
||||||
"PAT_국적",
|
"PAT_국적",
|
||||||
|
|||||||
@@ -387,11 +387,19 @@ public sealed class OracleLegacyFormStore
|
|||||||
{
|
{
|
||||||
var stamp = ServerClock.Read(connection, transaction);
|
var stamp = ServerClock.Read(connection, transaction);
|
||||||
// ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다
|
// ShtUsrDesYon='Y' 하드코딩 — 여기서 만든 서식은 서식생성기가 만든 것이므로 열기 게이트를 자동 통과한다
|
||||||
|
//
|
||||||
|
// ShtExpDte='29991231' / ShtHspYon='Y' 는 빼면 안 되는 값이다(--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 은 기록지정보·자동생성 등 목록이 ShtHspYon='Y' 로 거르며,
|
||||||
|
// 디자이너가 만든 서식(ShtUsrDesYon='Y') 169건 중 160건이 'Y' 다.
|
||||||
NonQuery(connection, transaction,
|
NonQuery(connection, transaction,
|
||||||
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
|
"INSERT INTO E_ShtMst (ShtCod, ShtAdpDte, ShtExpDte, ShtKorNam, ShtClsCod, ShtTyp, ShtUseYon, " +
|
||||||
"ShtUsrDesYon, ShtUseSdg, ShtUseSct, ShtUseEmr, ShtRedAth, ShtWrtTyp, ShtCneYon, ShtTraSto, " +
|
"ShtHspYon, 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, '29991231', :nam, :cls, 'D', 'Y', 'Y', 'Y', 'Y', 'Y', 'Y', 'A', '9A', 'N', 'N', 'N', :uid, :dtm)",
|
||||||
new OracleParameter("cod", shtCod),
|
new OracleParameter("cod", shtCod),
|
||||||
new OracleParameter("adp", stamp.Date8),
|
new OracleParameter("adp", stamp.Date8),
|
||||||
new OracleParameter("nam", korName),
|
new OracleParameter("nam", korName),
|
||||||
|
|||||||
@@ -142,6 +142,11 @@ public partial class App : Application
|
|||||||
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
|
return Diagnostics.DbSmoke.RunRow(args[1], args[2]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (args.Length >= 2 && args[0] == "--db-shtmst")
|
||||||
|
{
|
||||||
|
return Diagnostics.DbSmoke.RunShtMstReport(args[1]);
|
||||||
|
}
|
||||||
|
|
||||||
if (args.Length >= 3 && args[0] == "--db-sample")
|
if (args.Length >= 3 && args[0] == "--db-sample")
|
||||||
{
|
{
|
||||||
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
|
return Diagnostics.DbSmoke.RunSample(args[1], args[2]);
|
||||||
|
|||||||
@@ -615,6 +615,59 @@ public static class DbSmoke
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// 서식 마스터 등록 관련 컬럼 값 분포(--db-shtmst) — 신규 등록 기본값을 실측으로 정하기 위한 집계(읽기 전용).
|
||||||
|
///
|
||||||
|
/// 관행값을 추측으로 넣으면 등록은 성공하는데 목록에서만 사라지는 종류의 사고가 난다
|
||||||
|
/// (예: ShtExpDte 가 비면 dtEMRLoader 의 NVL(ShtExpDte,' ')='29991231' 조건에서 통째로 탈락).
|
||||||
|
/// </summary>
|
||||||
|
public static int RunShtMstReport(string reportPath)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var config = ConfigService.Current;
|
||||||
|
using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString);
|
||||||
|
connection.Open();
|
||||||
|
var lines = new List<string>();
|
||||||
|
var columns = new[] { "ShtHspYon", "ShtExpDte", "ShtUseYon", "ShtUsrDesYon", "ShtTyp", "ShtCneYon" };
|
||||||
|
foreach (var column in columns)
|
||||||
|
{
|
||||||
|
using var command = connection.CreateCommand();
|
||||||
|
command.CommandText =
|
||||||
|
$"SELECT NVL({column},'(null)') v, COUNT(*) c FROM E_ShtMst " +
|
||||||
|
$"GROUP BY NVL({column},'(null)') ORDER BY COUNT(*) DESC";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var parts = new List<string>();
|
||||||
|
var rank = 0;
|
||||||
|
while (reader.Read() && rank++ < 8)
|
||||||
|
{
|
||||||
|
parts.Add($"{reader.GetString(0)}={reader.GetDecimal(1)}");
|
||||||
|
}
|
||||||
|
lines.Add($"{column,-14} {string.Join(" ", parts)}");
|
||||||
|
}
|
||||||
|
// 디자이너가 만든 서식만(ShtUsrDesYon='Y') — 우리 등록 경로가 흉내 내야 할 모집단
|
||||||
|
using (var command = connection.CreateCommand())
|
||||||
|
{
|
||||||
|
command.CommandText =
|
||||||
|
"SELECT NVL(ShtHspYon,'(null)') h, NVL(ShtExpDte,'(null)') e, COUNT(*) c FROM E_ShtMst " +
|
||||||
|
"WHERE ShtUsrDesYon = 'Y' GROUP BY NVL(ShtHspYon,'(null)'), NVL(ShtExpDte,'(null)') ORDER BY COUNT(*) DESC";
|
||||||
|
using var reader = command.ExecuteReader();
|
||||||
|
var rank = 0;
|
||||||
|
while (reader.Read() && rank++ < 10)
|
||||||
|
{
|
||||||
|
lines.Add($" 디자이너서식 ShtHspYon={reader.GetString(0)} ShtExpDte={reader.GetString(1)} : {reader.GetDecimal(2)}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
File.WriteAllText(reportPath, "실패: " + ex.Message);
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
|
/// <summary>테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용</summary>
|
||||||
public static int RunSample(string tableName, string reportPath, int max = 12)
|
public static int RunSample(string tableName, string reportPath, int max = 12)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -177,6 +177,11 @@ public static class EditSmoke
|
|||||||
designer.AddPage();
|
designer.AddPage();
|
||||||
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
|
Check("페이지 추가: 2페이지", designer.Pages.Count == 2
|
||||||
&& designer.Document.Pages.Count == 2);
|
&& designer.Document.Pages.Count == 2);
|
||||||
|
// 루트 이름이 겹치면 E_SctMst 동기화가 둘째 루트를 첫째 행에 매핑해 항목 계층이 깨진다
|
||||||
|
Check("페이지 추가: 루트 이름 유일",
|
||||||
|
designer.Document.Pages.Select(p => p.Root.Id).Distinct(StringComparer.OrdinalIgnoreCase).Count()
|
||||||
|
== designer.Document.Pages.Count,
|
||||||
|
string.Join("/", designer.Document.Pages.Select(p => p.Root.Id)));
|
||||||
designer.SelectedPage = designer.Pages[1];
|
designer.SelectedPage = designer.Pages[1];
|
||||||
designer.ApplyPaperSize(794, 1123);
|
designer.ApplyPaperSize(794, 1123);
|
||||||
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
|
Check("용지 크기 변경(A4)", designer.Pages[1].WidthDip == 794 && designer.Pages[1].HeightDip == 1123);
|
||||||
@@ -424,6 +429,11 @@ public static class EditSmoke
|
|||||||
Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨",
|
Check("레이어: 페이지 복제 — 그룹이 원본과 분리됨",
|
||||||
copied.Controls.Where(c => c.Model.GroupId is not null)
|
copied.Controls.Where(c => c.Model.GroupId is not null)
|
||||||
.All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId)));
|
.All(c => lay.Pages[0].Controls.All(o => o.Model.GroupId != c.Model.GroupId)));
|
||||||
|
// Clone 은 Root.Id 까지 복사한다 — 자식만 재발급하면 같은 이름의 루트가 둘 생긴다
|
||||||
|
Check("레이어: 페이지 복제 — 루트 이름도 새로 발급",
|
||||||
|
lay.Document.Pages.Select(p => p.Root.Id)
|
||||||
|
.Distinct(StringComparer.OrdinalIgnoreCase).Count() == lay.Document.Pages.Count,
|
||||||
|
string.Join("/", lay.Document.Pages.Select(p => p.Root.Id)));
|
||||||
}
|
}
|
||||||
Check("레이어: 전 페이지가 아웃라인에 나옴",
|
Check("레이어: 전 페이지가 아웃라인에 나옴",
|
||||||
lay.LayerRows.OfType<LayerPageViewModel>().Count() == lay.Pages.Count);
|
lay.LayerRows.OfType<LayerPageViewModel>().Count() == lay.Pages.Count);
|
||||||
@@ -642,6 +652,12 @@ public static class EditSmoke
|
|||||||
.OrderBy(c => c.X)
|
.OrderBy(c => c.X)
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
|
// 2페이지에 이미 순서가 잡힌 컨트롤을 둔다 — 1페이지만 편집했는데 여기가 밀리면 안 된다
|
||||||
|
designer.AddPage();
|
||||||
|
designer.AddControlAt("TextBox", new Point(120, designer.Pages[1].OffsetY + 120));
|
||||||
|
var otherPageBox = designer.Pages[1].Controls.First(c => c.Type == "TextBox");
|
||||||
|
otherPageBox.Model.Props.SetText("TabIndex", "300");
|
||||||
|
|
||||||
designer.ToggleTabOrderMode(); // 시작
|
designer.ToggleTabOrderMode(); // 시작
|
||||||
Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode
|
Check("탭순서: 모드 진입 + 배지 생성", designer.IsTabOrderMode
|
||||||
&& designer.Overlay.TabBadges.Count >= 3,
|
&& designer.Overlay.TabBadges.Count >= 3,
|
||||||
@@ -664,14 +680,87 @@ public static class EditSmoke
|
|||||||
$"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}");
|
$"실제 {tabTargets[2].Model.Props.GetText("TabIndex")}/{tabTargets[1].Model.Props.GetText("TabIndex")}/{tabTargets[0].Model.Props.GetText("TabIndex")}");
|
||||||
Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)",
|
Check("탭순서: 미클릭 컨트롤 뒤 순번(999999+)",
|
||||||
Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true);
|
Find("Label").Model.Props.GetText("TabIndex")?.StartsWith("1000") == true);
|
||||||
|
// 레거시는 페이지마다 별도 디자인 서피스 — 손대지 않은 페이지는 그대로 남아야 한다
|
||||||
|
Check("탭순서: 손대지 않은 페이지는 불변",
|
||||||
|
otherPageBox.Model.Props.GetText("TabIndex") == "300",
|
||||||
|
$"실제 {otherPageBox.Model.Props.GetText("TabIndex")}");
|
||||||
designer.Undo.Undo();
|
designer.Undo.Undo();
|
||||||
Check("탭순서: Undo 원복",
|
Check("탭순서: Undo 원복",
|
||||||
designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0");
|
designer.Pages[0].Controls.First(c => c.Type == "TextBox" && c.Y >= 600).Model.Props.GetText("TabIndex") is null or "0");
|
||||||
|
|
||||||
// 정리
|
// 정리
|
||||||
|
designer.SelectedPage = designer.Pages[1];
|
||||||
|
designer.RemoveSelectedPageForSmoke();
|
||||||
|
designer.SelectedPage = designer.Pages[0];
|
||||||
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
|
designer.Selection.Set(designer.Pages[0].Controls.Where(c => c.Y >= 600).ToList());
|
||||||
designer.DeleteSelection();
|
designer.DeleteSelection();
|
||||||
|
|
||||||
|
// 20-5b) 이름 채번 규약 — 지운 번호를 다시 쓰지 않는다(레거시 clsNameCreationService).
|
||||||
|
// 이름은 저장된 답변(E_SctDta.SctObjNam)이 붙는 열쇠다. 번호를 재사용하면
|
||||||
|
// 지운 칸의 과거 입력이 새 칸에 그대로 나타나고, 액션 대상·CalcBox 수식도 오배선된다.
|
||||||
|
{
|
||||||
|
var pool = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "TextBox1", "TextBox2", "TextBox3" };
|
||||||
|
pool.Remove("TextBox2");
|
||||||
|
Check("이름 채번: 빈 번호를 메우지 않고 최대+1",
|
||||||
|
Services.IdGenerator.NextId(pool, "TextBox") == "TextBox4");
|
||||||
|
|
||||||
|
var odd = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "TextBoxTotal", "TextBox2a", "MyTextBox9" };
|
||||||
|
Check("이름 채번: 숫자 아닌 접미사는 번호로 보지 않음",
|
||||||
|
Services.IdGenerator.NextId(odd, "TextBox") == "TextBox1");
|
||||||
|
|
||||||
|
// 문서 경유 — 가운데 번호를 지워도 그 이름이 되살아나면 안 된다.
|
||||||
|
// (가장 큰 번호를 지운 경우는 최대값 자체가 내려가므로 같은 이름이 다시 나오는 것이 정상이다 —
|
||||||
|
// 레거시 clsNameCreationService 도 현재 사용 중인 이름만 보고 최대+1 을 계산한다.)
|
||||||
|
var beforeAdd = designer.Pages[0].Controls.ToList();
|
||||||
|
designer.AddControlAt("TextBox", new Point(120, 700));
|
||||||
|
designer.AddControlAt("TextBox", new Point(320, 700));
|
||||||
|
var pair = designer.Pages[0].Controls.Except(beforeAdd).ToList();
|
||||||
|
Check("이름 채번: 시험용 컨트롤 2개 배치", pair.Count == 2, $"실제 {pair.Count}");
|
||||||
|
var middle = pair.OrderBy(c => c.Id.Length).ThenBy(c => c.Id, StringComparer.Ordinal).First();
|
||||||
|
var removedId = middle.Id;
|
||||||
|
designer.Selection.SetSingle(middle);
|
||||||
|
designer.DeleteSelection();
|
||||||
|
var beforeReborn = designer.Pages[0].Controls.ToList();
|
||||||
|
designer.AddControlAt("TextBox", new Point(520, 700));
|
||||||
|
var reborn = designer.Pages[0].Controls.Except(beforeReborn).Single();
|
||||||
|
Check("이름 채번: 삭제한 가운데 이름을 재사용하지 않음", reborn.Id != removedId,
|
||||||
|
$"삭제 {removedId} → 신규 {reborn.Id}");
|
||||||
|
designer.Selection.Set(pair.Where(c => c != middle).Append(reborn).ToList());
|
||||||
|
designer.DeleteSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20-5c) 선택이 빈 상태의 Ctrl+드래그 — 주선택이 없어도 죽지 않아야 한다.
|
||||||
|
// Ctrl/Shift 클릭은 선택을 바꾸지 않고 업에서 토글하므로, 빈 선택으로 드래그가 시작될 수 있다.
|
||||||
|
{
|
||||||
|
var dragTarget = Find("Label");
|
||||||
|
var box = designer.WorldBoundsOf(dragTarget);
|
||||||
|
var grab = new Point(box.X + box.Width / 2, box.Y + box.Height / 2);
|
||||||
|
designer.Selection.Clear();
|
||||||
|
var survived = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
designer.Interaction.PointerDown(grab, ctrl);
|
||||||
|
designer.Interaction.PointerMove(new Point(grab.X + 40, grab.Y + 40), ctrl);
|
||||||
|
designer.Interaction.PointerUp(new Point(grab.X + 40, grab.Y + 40), ctrl);
|
||||||
|
}
|
||||||
|
catch (Exception ex)
|
||||||
|
{
|
||||||
|
survived = false;
|
||||||
|
Check("빈 선택 Ctrl+드래그: 예외 없음", false, ex.GetType().Name + ": " + ex.Message);
|
||||||
|
}
|
||||||
|
if (survived)
|
||||||
|
{
|
||||||
|
Check("빈 선택 Ctrl+드래그: 예외 없음", true);
|
||||||
|
}
|
||||||
|
designer.Selection.Clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 20-5d) 태그 카탈로그 — 레거시에서 주석 처리된 태그를 고를 수 있으면 안 된다
|
||||||
|
Check("태그 카탈로그: 미구현 3종 제외",
|
||||||
|
!SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_BMI")
|
||||||
|
&& !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_IBW")
|
||||||
|
&& !SheetMe.Core.Catalog.LegacyTagCatalog.DataInterfaceTags.Contains("PAT_IBW_퍼센트"));
|
||||||
|
|
||||||
// 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가
|
// 20-6) 전체 속성 그리드(고급) — 토글 → raw 편집 → 속성 추가
|
||||||
var advTarget = Find("TextBox");
|
var advTarget = Find("TextBox");
|
||||||
designer.Selection.SetSingle(advTarget);
|
designer.Selection.SetSingle(advTarget);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Globalization;
|
||||||
using SheetMe.Core.Models;
|
using SheetMe.Core.Models;
|
||||||
|
|
||||||
namespace SheetMe.Designer.Services;
|
namespace SheetMe.Designer.Services;
|
||||||
@@ -17,18 +18,40 @@ public static class IdGenerator
|
|||||||
return used;
|
return used;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>타입 기반 유일 Id 생성 — 발급한 Id 는 used 집합에 추가된다(연속 발급 안전)</summary>
|
/// <summary>
|
||||||
|
/// 타입 기반 유일 Id 생성 — 같은 타입의 <b>최대 번호 + 1</b>(레거시 clsNameCreationService 규약).
|
||||||
|
/// 발급한 Id 는 used 집합에 추가된다(연속 발급 안전).
|
||||||
|
///
|
||||||
|
/// 빈 번호를 메우지 않는 것이 핵심이다. TextBox3 을 지우고 새 TextBox 를 놓으면
|
||||||
|
/// 최소 빈칸 방식은 다시 TextBox3 을 발급하는데, 이름은 저장된 답변(E_SctDta.SctObjNam)이
|
||||||
|
/// 붙는 열쇠라서 <b>지운 칸의 과거 입력이 새 칸에 그대로 나타난다</b>. 액션 대상과
|
||||||
|
/// CalcBox 수식도 이름으로 서로를 가리키므로 같은 이름을 재사용하면 조용히 오배선된다.
|
||||||
|
/// </summary>
|
||||||
public static string NextId(HashSet<string> used, string type)
|
public static string NextId(HashSet<string> used, string type)
|
||||||
{
|
{
|
||||||
for (var i = 1; ; i++)
|
var next = 1;
|
||||||
|
foreach (var id in used)
|
||||||
{
|
{
|
||||||
var candidate = type + i;
|
if (id.Length <= type.Length || !id.StartsWith(type, StringComparison.OrdinalIgnoreCase))
|
||||||
if (used.Add(candidate))
|
|
||||||
{
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// 접미사가 순수 숫자일 때만 번호로 본다("TextBox1a" 나 "TextBoxTotal" 은 무시)
|
||||||
|
if (int.TryParse(id.AsSpan(type.Length), NumberStyles.None, CultureInfo.InvariantCulture, out var number)
|
||||||
|
&& number >= next)
|
||||||
|
{
|
||||||
|
next = number + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
var candidate = type + next.ToString(CultureInfo.InvariantCulture);
|
||||||
|
// 방어: "TextBox0" 같은 값이 이미 있으면 next 가 1 로 남아 충돌할 수 있다
|
||||||
|
while (!used.Add(candidate))
|
||||||
|
{
|
||||||
|
next++;
|
||||||
|
candidate = type + next.ToString(CultureInfo.InvariantCulture);
|
||||||
|
}
|
||||||
return candidate;
|
return candidate;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
|
/// <summary>단건 발급 편의 — 문서 스캔 후 1개 생성</summary>
|
||||||
public static string NextId(FormDocument document, string type)
|
public static string NextId(FormDocument document, string type)
|
||||||
|
|||||||
@@ -282,7 +282,10 @@ public sealed class InteractionController
|
|||||||
private void BeginMove()
|
private void BeginMove()
|
||||||
{
|
{
|
||||||
CaptureOrigBounds();
|
CaptureOrigBounds();
|
||||||
var page = designer.PageOf(designer.Selection.Primary!) ?? designer.Pages.FirstOrDefault();
|
// Primary 는 없을 수 있다(선택 항목은 있는데 주선택이 비는 경로가 있다) —
|
||||||
|
// ?! 로 넘기면 PageOf → RootAncestorOf 가 즉시 역참조해 이동 시작이 예외로 죽는다
|
||||||
|
var anchor = designer.Selection.Primary ?? designer.Selection.Items.FirstOrDefault();
|
||||||
|
var page = (anchor is null ? null : designer.PageOf(anchor)) ?? designer.Pages.FirstOrDefault();
|
||||||
if (page is not null)
|
if (page is not null)
|
||||||
{
|
{
|
||||||
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
designer.Snap.BeginDrag(page, designer.Selection.Items.ToHashSet());
|
||||||
|
|||||||
@@ -400,15 +400,39 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
RebuildLayerRows();
|
RebuildLayerRows();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>페이지 추가 — 문서 끝에 빈 페이지(720×856)</summary>
|
/// <summary>
|
||||||
|
/// 페이지 추가 — 문서 끝에 빈 페이지(720×856).
|
||||||
|
///
|
||||||
|
/// 루트 이름은 페이지 수가 아니라 <b>사용 중인 이름</b>을 보고 정한다.
|
||||||
|
/// 페이지 수로 지으면 루트가 MDesignerHost1·MDesignerHost3 인 2장짜리 서식에서
|
||||||
|
/// 곧바로 MDesignerHost3 이 중복된다(레거시는 전 페이지를 훑어 최대번호+1 을 붙인다).
|
||||||
|
/// </summary>
|
||||||
public void AddPage()
|
public void AddPage()
|
||||||
{
|
{
|
||||||
Undo.Snapshot();
|
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();
|
RebuildFromDocument();
|
||||||
SelectedPage = Pages.LastOrDefault();
|
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>
|
/// <summary>
|
||||||
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
|
/// 페이지 복제 — 용지 설정과 컨트롤을 통째로 베껴 바로 뒤에 끼운다.
|
||||||
///
|
///
|
||||||
@@ -428,6 +452,9 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
var clone = target.Model.Clone();
|
var clone = target.Model.Clone();
|
||||||
var usedIds = IdGenerator.CollectUsed(document);
|
var usedIds = IdGenerator.CollectUsed(document);
|
||||||
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
|
var groupRemap = new Dictionary<string, string>(StringComparer.Ordinal);
|
||||||
|
// 페이지 루트도 이름을 새로 받아야 한다 — Clone 이 Root.Id 까지 복사하므로
|
||||||
|
// 자식만 재발급하면 한 서식에 같은 이름의 루트가 둘 생긴다
|
||||||
|
RenamePageRoot(clone, usedIds);
|
||||||
foreach (var element in clone.Controls)
|
foreach (var element in clone.Controls)
|
||||||
{
|
{
|
||||||
AssignNewIds(element, usedIds);
|
AssignNewIds(element, usedIds);
|
||||||
@@ -863,7 +890,15 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
Overlay.TabBadges.Clear();
|
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()
|
private void ApplyTabOrder()
|
||||||
{
|
{
|
||||||
if (tabOrderSequence.Count == 0)
|
if (tabOrderSequence.Count == 0)
|
||||||
@@ -872,8 +907,16 @@ public sealed class DesignerViewModel : ViewModelBase
|
|||||||
}
|
}
|
||||||
Undo.Snapshot();
|
Undo.Snapshot();
|
||||||
|
|
||||||
|
var touched = new HashSet<PageViewModel>();
|
||||||
|
foreach (var vm in tabOrderSequence)
|
||||||
|
{
|
||||||
|
if (PageOf(vm) is { } page)
|
||||||
|
{
|
||||||
|
touched.Add(page);
|
||||||
|
}
|
||||||
|
}
|
||||||
var index = 0;
|
var index = 0;
|
||||||
foreach (var vm in AllControls())
|
foreach (var vm in AllControls().Where(v => PageOf(v) is { } p && touched.Contains(p)))
|
||||||
{
|
{
|
||||||
index++;
|
index++;
|
||||||
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
|
vm.Model.Props.SetText("TabIndex", (999999 + index).ToString());
|
||||||
|
|||||||
@@ -373,9 +373,15 @@ internal sealed class MainViewModel : ViewModelBase
|
|||||||
/// <summary>종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록</summary>
|
/// <summary>종료 확인을 이미 통과했는지 — MainView.Closing 이 재질문하지 않도록</summary>
|
||||||
public bool IsShuttingDown { get; private set; }
|
public bool IsShuttingDown { get; private set; }
|
||||||
|
|
||||||
/// <summary>이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null</summary>
|
/// <summary>
|
||||||
|
/// 이미 열린 DB 서식이면 해당 탭 활성화 — 없으면 null.
|
||||||
|
///
|
||||||
|
/// 이력본(HistorySdgKey 가 있는 읽기 전용 스냅샷)은 제외한다. 같은 ShtCod 를 갖고 있어서
|
||||||
|
/// 걸러내지 않으면 현재본을 열려는 요청이 옛 이력 탭을 활성화하고, 사용자는 현재본을
|
||||||
|
/// 편집한다고 믿은 채 스냅샷을 고치게 된다.
|
||||||
|
/// </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.HistorySdgKey is null && d.Document.FormId == shtCod);
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
|
/// 서식생성기 사용 권한 확인(E_ShtMst.ShtUsrDesYon) — 레거시 DisplaySheetDesign 게이트 이식.
|
||||||
|
|||||||
Reference in New Issue
Block a user