using System.IO; using SheetMe.Core.Models; using SheetMe.Core.Serialization; using SheetMe.Data.Stores; using SheetMe.Designer.Services; namespace SheetMe.Designer.Diagnostics; /// /// 실DB 검증 진단. /// --db-smoke <리포트> [N] : 읽기 전용 — 활성 디자인 N건 일괄 Read→Write 의미론 diff 통계. /// --db-save-smoke <서식코드> <리포트> : 쓰기 — 무변경 재저장 후 재로드 비교 + E_SctMst 행 수 검증. /// public static class DbSmoke { #region Nested Types /// /// 아무것도 해석하지 않는 사슬 꼬리 — 환자 해석기만 판정하려면 뒤가 조용해야 한다. /// 세션 해석기를 꼬리로 쓰면 시각 태그가 값이 되어 "환자 해석기가 됐다"와 섞인다. /// private sealed class NullTagResolver : SheetMe.Core.Catalog.ITagValueResolver { public SheetMe.Core.Catalog.TagValue Resolve(string tag) => new(false, "해석기 없음"); } #endregion #region Methods /// 읽기 전용 일괄 왕복 스모크 public static int RunReadSmoke(string reportPath, int maxSheets) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000); lines.Add($"E_ShtMst 서식 {sheets.Count}건, 디자인 보유 {sheets.Count(s => s.HasDesign)}건"); var targets = sheets.Where(s => s.HasDesign).Take(maxSheets).ToList(); var perfect = 0; var withWarnings = 0; var diffSheets = new List(); var failed = new List(); var placeholderTypes = new Dictionary(StringComparer.Ordinal); foreach (var sheet in targets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } var document = serializer.Read(raw.Value.Xml); var rewritten = serializer.Write(document); var diffs = XmlSemanticDiff.Compare(raw.Value.Xml, rewritten, maxDiffs: 5); foreach (var warning in document.Meta.ReadWarnings.Where(w => w.StartsWith("미지원"))) { var type = warning[(warning.IndexOf('(') + 1)..].TrimEnd(')'); placeholderTypes[type] = placeholderTypes.GetValueOrDefault(type) + 1; } if (diffs.Count == 0) { perfect++; if (document.Meta.ReadWarnings.Count > 0) { withWarnings++; } } else { diffSheets.Add($" {sheet.ShtCod} {sheet.Name}: {diffs[0]}"); } } catch (Exception ex) { failed.Add($" {sheet.ShtCod} {sheet.Name}: {ex.Message}"); } } lines.Add($"왕복 검사 {targets.Count}건 → 의미론 diff 0: {perfect}건 (미지원 컨트롤 보존 포함 {withWarnings}건), diff 발생: {diffSheets.Count}건, 예외: {failed.Count}건"); if (placeholderTypes.Count > 0) { lines.Add("미지원(자리표시 보존) 타입 분포: " + string.Join(", ", placeholderTypes.OrderByDescending(kv => kv.Value).Select(kv => $"{kv.Key}×{kv.Value}"))); } if (diffSheets.Count > 0) { lines.Add("[diff 발생 서식]"); lines.AddRange(diffSheets.Take(20)); } if (failed.Count > 0) { lines.Add("[예외 서식]"); lines.AddRange(failed.Take(20)); } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return diffSheets.Count == 0 && failed.Count == 0 ? 0 : 1; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 권한 게이트 영향 범위(--db-gate) — ShtUsrDesYon 값별 서식 수를 집계한다. /// 게이트 도입 전에 "갑자기 서식이 안 열린다"는 문의 규모를 미리 알기 위한 읽기 전용 진단. /// /// /// 페이지 루트(MDesignerHost) 속성 전수 집계 — 페이지 속성 편집기의 기본값 근거. /// /// 레거시 PropertyGrid 는 속성이 없으면 WinForms 기본값을 보여 준다(BackColor 는 Control). /// 우리가 어떤 키를 얼마나 마주치는지, 어떤 값이 실제로 쓰이는지 알아야 기본 표시를 정할 수 있다. /// public static int RunPagePropsReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); var keyCounts = new Dictionary(StringComparer.Ordinal); var backColors = new Dictionary(StringComparer.Ordinal); var fonts = new Dictionary(StringComparer.Ordinal); var pages = 0; var docs = 0; foreach (var sheet in sheets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } docs++; foreach (var page in serializer.Read(raw.Value.Xml).Pages) { pages++; foreach (var key in page.Root.Props.Keys) { keyCounts[key] = keyCounts.GetValueOrDefault(key) + 1; } var back = page.Root.Props.GetText("BackColor") ?? "(없음)"; backColors[back] = backColors.GetValueOrDefault(back) + 1; var font = page.Root.Props.GetText("Font") ?? "(없음)"; fonts[font] = fonts.GetValueOrDefault(font) + 1; } } catch (Exception ex) { lines.Add($" 실패 {sheet.ShtCod}: {ex.Message}"); } } // 속성 패널을 '여는 것만으로' 문서가 바뀌면 안 된다. // 값이 없는 페이지에 기본값을 지어내 저장해 버리면 왕복 diff 0 이 조용히 깨진다. var mutated = new List(); var checkedDocs = 0; foreach (var sheet in sheets) { if (checkedDocs >= 40) { break; } try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } var document = serializer.Read(raw.Value.Xml); // 값이 없는 페이지를 가진 서식만 본다 — 지어낸 값이 생기는지가 관심사다 if (!document.Pages.Any(p => p.Root.Props.GetText("BackColor") is null)) { continue; } checkedDocs++; var before = serializer.Write(document); var designer = new ViewModels.DesignerViewModel(document); designer.ActivatePage(designer.Pages[0]); var after = serializer.Write(designer.Document); if (!string.Equals(before, after, StringComparison.Ordinal)) { mutated.Add($" {sheet.ShtCod} {sheet.Name}"); } } catch (Exception ex) { mutated.Add($" {sheet.ShtCod} 예외: {ex.Message}"); } } lines.Insert(0, mutated.Count == 0 ? $"[열기 무변경] BackColor 없는 서식 {checkedDocs}건 — 속성 패널을 열어도 XML 동일" : $"[열기 변경 발생] {mutated.Count}건 — 아래 서식은 여는 것만으로 XML 이 바뀐다"); lines.InsertRange(1, mutated); lines.Insert(mutated.Count + 1, string.Empty); lines.Insert(0, $"디자인 {docs}건 / 페이지 {pages}장"); lines.Add(string.Empty); lines.Add("[페이지 루트 속성 키 분포]"); foreach (var (key, count) in keyCounts.OrderByDescending(p => p.Value)) { lines.Add($" {key,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)"); } lines.Add(string.Empty); lines.Add("[BackColor 값 분포]"); foreach (var (value, count) in backColors.OrderByDescending(p => p.Value)) { lines.Add($" {value,-24} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)"); } lines.Add(string.Empty); lines.Add("[Font 값 분포]"); foreach (var (value, count) in fonts.OrderByDescending(p => p.Value).Take(15)) { lines.Add($" {value,-30} {count,6}장 ({count * 100.0 / Math.Max(1, pages):0.0}%)"); } } catch (Exception ex) { lines.Add($"예외: {ex}"); } File.WriteAllLines(reportPath, lines); return 0; } /// /// 색 속성 값 전수 집계 — 색상 행의 인라인 팔레트를 실제 사용값으로 채우기 위한 근거. /// 레거시는 명명색(White/Window/Control…)과 "R, G, B" 를 섞어 쓴다. /// public static int RunColorReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); var byKey = new Dictionary>(StringComparer.Ordinal); var total = 0; void Visit(Core.Models.ControlElement element) { foreach (var key in new[] { "BackColor", "ForeColor" }) { if (element.Props.GetText(key) is not { } value || value.Length == 0) { continue; } if (!byKey.TryGetValue(key, out var bucket)) { bucket = new Dictionary(StringComparer.Ordinal); byKey[key] = bucket; } bucket[value] = bucket.GetValueOrDefault(value) + 1; total++; } foreach (var child in element.Children) { Visit(child); } } foreach (var sheet in sheets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } foreach (var page in serializer.Read(raw.Value.Xml).Pages) { Visit(page.Root); } } catch { // 색 집계는 통계용이라 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다) } } lines.Add($"색 속성 값 {total}건"); foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal)) { lines.Add(string.Empty); lines.Add($"[{key}] 서로 다른 값 {bucket.Count}종"); foreach (var (value, count) in bucket.OrderByDescending(p => p.Value).Take(25)) { lines.Add($" {value,-22} {count,7}건"); } } } catch (Exception ex) { lines.Add($"예외: {ex}"); } File.WriteAllLines(reportPath, lines); return 0; } public static int RunGateReport(string reportPath) { try { var config = ConfigService.Current; 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 { "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; } } /// /// 서식 목록 그룹핑 후보 분포(--db-cls) — 분류 컬럼별 값 분포를 집계한다. /// /// 그룹 기준을 잘못 고르면 "묶였는데 오히려 못 찾는" 결과가 된다. 판정 기준 셋: /// ① 그룹 수가 과다하지 않을 것(수십 개면 스크롤이 더 길어진다) /// ② 한 그룹에 전부 몰리지 않을 것(그룹핑 효과 0) /// ③ 빈 값 비율이 낮을 것(대부분이 '(미분류)'로 가면 의미가 없다) /// 목록에 실제로 뜨는 모집단(ShtTyp≠'S')만 세고, 디자인 보유 건수를 함께 낸다. /// public static int RunClassReport(string reportPath) { var candidates = new[] { "ShtClsCod", "ShtDepCls", "ShtTyp", "ShtUsrTyp", "ShtPatTyp", "ShtWrtTyp", "ShtLstTyp" }; try { var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); var lines = new List { "서식 목록 그룹핑 후보 분포 — 모집단: E_ShtMst 에서 스캔서식(ShtTyp='S') 제외", string.Empty, }; // 분류 코드 → 한글 라벨 마스터 실측(EmrTreeDataBusiness.cs:55 의 조인 규약 검증) using (var command = connection.CreateCommand()) { command.CommandText = "SELECT TRIM(DtlCod), DtlCodNam, NVL(DtlDspSeq,'-'), NVL(DtlStrDte,'-'), NVL(DtlExpDte,'-') " + "FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' ORDER BY DtlDspSeq, DtlCod"; lines.Add("[M_DtlMst DtlTblCod='EMR_SHEET_GROUP'] — ShtClsCod 라벨 마스터 (DspSeq 순)"); using var reader = command.ExecuteReader(); while (reader.Read()) { lines.Add($" {reader.GetString(0),-6} → {(reader.IsDBNull(1) ? "(NULL)" : reader.GetString(1)),-20} " + $"seq={reader.GetString(2),-6} {reader.GetString(3)}~{reader.GetString(4)}"); } lines.Add(string.Empty); } // 조인 행 증식 검사 — M_DtlMst 는 PK 에 DtlStrDte 가 들어가 같은 코드가 기간별 다중행일 수 있다. // 증식하면 서식이 목록에 두 번 뜬다(사용자는 '데이터가 깨졌다'로 읽는다). using (var command = connection.CreateCommand()) { command.CommandText = "SELECT TRIM(DtlCod), COUNT(*) FROM M_DtlMst WHERE DtlTblCod = 'EMR_SHEET_GROUP' " + "GROUP BY TRIM(DtlCod) HAVING COUNT(*) > 1 ORDER BY 2 DESC"; lines.Add("[조인 증식 검사] 같은 DtlCod 가 2행 이상인 코드"); using var reader = command.ExecuteReader(); var any = false; while (reader.Read()) { any = true; lines.Add($" ⚠ {reader.GetString(0)} — {reader.GetDecimal(1)}행"); } lines.Add(any ? " → 조인에 최신행 1건 선택이 반드시 필요" : " 없음(단, 스키마상 가능하므로 방어는 유지)"); lines.Add(string.Empty); } // 마스터에 없는 고아 분류 코드 — 이 건수만큼 헤더가 원시 코드로 뜬다 using (var command = connection.CreateCommand()) { command.CommandText = "SELECT NVL(TRIM(s.ShtClsCod),'(빈값)'), COUNT(*) FROM E_ShtMst s " + "WHERE NVL(s.ShtTyp,' ') <> 'S' AND NOT EXISTS (" + " SELECT 1 FROM M_DtlMst d WHERE d.DtlTblCod = 'EMR_SHEET_GROUP' " + " AND TRIM(d.DtlCod) = TRIM(s.ShtClsCod)) " + "GROUP BY NVL(TRIM(s.ShtClsCod),'(빈값)') ORDER BY 2 DESC"; lines.Add("[고아 코드] E_ShtMst 에는 있으나 마스터에 없는 분류"); using var reader = command.ExecuteReader(); var any = false; while (reader.Read()) { any = true; lines.Add($" {reader.GetString(0)} — {reader.GetDecimal(1)}건"); } if (!any) { lines.Add(" 없음"); } lines.Add(string.Empty); } // 폐기 서식(ShtUseYon='N')이 섞이면 그룹 헤더 건수가 부풀어 보인다 using (var command = connection.CreateCommand()) { command.CommandText = "SELECT NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)'), COUNT(*) " + "FROM E_ShtMst GROUP BY NVL(TRIM(ShtTyp),'(빈값)'), NVL(TRIM(ShtUseYon),'(빈값)') ORDER BY 3 DESC"; lines.Add("[모집단 검증] ShtTyp × ShtUseYon"); using var reader = command.ExecuteReader(); while (reader.Read()) { lines.Add($" ShtTyp={reader.GetString(0),-8} ShtUseYon={reader.GetString(1),-8} {reader.GetDecimal(2),5}건"); } lines.Add(string.Empty); } foreach (var column in candidates) { using var command = connection.CreateCommand(); command.CommandText = $"SELECT NVL(TRIM(s.{column}),'(빈값)') AS K, 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.{column}),'(빈값)') ORDER BY 2 DESC"; var rows = new List<(string Key, decimal Total, decimal WithDesign)>(); using (var reader = command.ExecuteReader()) { while (reader.Read()) { rows.Add((reader.GetString(0), reader.GetDecimal(1), reader.GetDecimal(2))); } } var all = rows.Sum(r => r.Total); var blank = rows.FirstOrDefault(r => r.Key == "(빈값)").Total; var top = rows.Count > 0 ? rows[0] : default; lines.Add($"=== {column} — 서로 다른 값 {rows.Count}개 / 빈값 {blank}건({(all == 0 ? 0 : blank * 100 / all)}%) / " + $"최대 그룹 '{top.Key}' {top.Total}건 ==="); foreach (var row in rows.Take(40)) { lines.Add($" {row.Key,-32} {row.Total,5}건 (디자인 {row.WithDesign,5})"); } if (rows.Count > 40) { lines.Add($" … 이하 {rows.Count - 40}개 생략"); } lines.Add(string.Empty); } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// /// 테이블/컬럼 이름 검색(--db-tables <패턴>) — 이름을 모르는 마스터 테이블을 찾을 때. /// 레거시 스키마에 코드 마스터가 여러 관례로 흩어져 있어(E_*/M_*), 이름 추측으로 헛도는 것을 막는다. /// 접근 가능한 모든 스키마를 훑되 소유자를 함께 표시한다(읽기 전용). /// public static int RunTableSearch(string pattern, string reportPath) { try { var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); var lines = new List(); using (var command = connection.CreateCommand()) { command.BindByName = true; command.CommandText = "SELECT OWNER, TABLE_NAME FROM ALL_TABLES WHERE TABLE_NAME LIKE UPPER(:p) " + "ORDER BY OWNER, TABLE_NAME"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern)); lines.Add($"[테이블명 LIKE {pattern}]"); using var reader = command.ExecuteReader(); while (reader.Read()) { lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}"); } } lines.Add(string.Empty); using (var command = connection.CreateCommand()) { command.BindByName = true; command.CommandText = "SELECT OWNER, TABLE_NAME, COLUMN_NAME FROM ALL_TAB_COLUMNS WHERE COLUMN_NAME LIKE UPPER(:p) " + "ORDER BY OWNER, TABLE_NAME, COLUMN_NAME"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("p", pattern)); lines.Add($"[컬럼명 LIKE {pattern}]"); using var reader = command.ExecuteReader(); var count = 0; while (reader.Read() && count++ < 200) { lines.Add($" {reader.GetString(0)}.{reader.GetString(1)}.{reader.GetString(2)}"); } } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// 테이블 컬럼 스키마 조회(--db-columns) — NOT NULL/타입 확인(읽기 전용) public static int RunColumns(string tableName, string reportPath) { try { var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT COLUMN_NAME, DATA_TYPE, NVL(DATA_LENGTH,0), NULLABLE " + "FROM USER_TAB_COLUMNS WHERE TABLE_NAME = UPPER(:t) ORDER BY COLUMN_ID"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", tableName)); var lines = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { lines.Add($"{reader.GetString(0),-16} {reader.GetString(1),-10} {reader.GetDecimal(2),5} " + $"{(reader.GetString(3) == "N" ? "NOT NULL" : "")}"); } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// 서식 마스터 행 주요 플래그 조회(--db-row) — 신규 등록 기본값 참조용(읽기 전용) public static int RunRow(string shtCod, string reportPath) { try { var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT NVL(ShtTyp,'-'), NVL(ShtUseYon,'-'), NVL(ShtUsrDesYon,'-'), NVL(ShtUseSdg,'-'), NVL(ShtUseSct,'-'), " + "NVL(ShtUseEmr,'-'), NVL(ShtEleSig,'-'), NVL(ShtEdtAth,'-'), NVL(ShtRedAth,'-'), NVL(ShtWrtTyp,'-'), " + "NVL(ShtEdtTyp,'-'), NVL(ShtViwTyp,'-'), NVL(ShtClsCod,'-'), NVL(ShtCneYon,'-'), NVL(ShtTraSto,'-') " + "FROM E_ShtMst WHERE ShtCod = :s"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCod)); using var reader = command.ExecuteReader(); if (!reader.Read()) { File.WriteAllText(reportPath, "행 없음"); return 1; } var names = new[] { "ShtTyp", "ShtUseYon", "ShtUsrDesYon", "ShtUseSdg", "ShtUseSct", "ShtUseEmr", "ShtEleSig", "ShtEdtAth", "ShtRedAth", "ShtWrtTyp", "ShtEdtTyp", "ShtViwTyp", "ShtClsCod", "ShtCneYon", "ShtTraSto" }; var lines = names.Select((n, i) => $"{n}={reader.GetString(i)}"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// /// 자동완성 팝업 동작 점검(--query-popup) — 위치와 소멸 규칙을 실제 창을 띄워 잰다. /// /// 이 둘은 눈으로만 보면 놓친다. 팝업이 옛 문맥을 물고 남아 있거나 엉뚱한 자리에 뜨는 문제가 /// 실제로 두 번 있었다(커서 이동 시 갱신 안 됨 / 열린 채로는 Placement 재계산 안 됨). /// public static int RunQueryPopupCheck(string reportPath) { var lines = new List(); var failures = 0; void Check(string label, bool ok, string detail = "") { if (!ok) { failures++; } lines.Add($"{(ok ? "PASS" : "FAIL")} {label}" + (detail.Length > 0 ? $" — {detail}" : string.Empty)); } try { var window = new Views.QueryEditorWindow("점검", string.Empty); // 화면 밖에 띄운다 — 진단이 사용자 화면을 가리지 않게 window.WindowStartupLocation = System.Windows.WindowStartupLocation.Manual; window.Left = -10000; window.Top = -10000; window.Show(); window.UpdateLayout(); var box = (System.Windows.Controls.TextBox)window.FindName("SqlBox")!; var popup = (System.Windows.Controls.Primitives.Popup)window.FindName("CompletionPopup")!; void Type(string text, int? caret = null) { box.Text = text; box.CaretIndex = caret ?? text.Length; // Text 를 프로그램으로 넣으면 TextChanged 시점의 커서가 아직 옛 자리다 — // 사용자가 친 것과 같은 상태로 맞춘 뒤 갱신을 부른다 window.RefreshCompletionForSmoke(); DrainDispatcher(); } // ① FROM 뒤에서 테이블 목록이 뜬다 Type("SELECT * FROM P_"); Check("FROM 뒤 목록이 열린다", popup.IsOpen); var firstOffset = (popup.HorizontalOffset, popup.VerticalOffset); // ② 팝업은 완성 중인 낱말의 시작에 붙는다 — 캐럿을 따라 오른쪽으로 흐르면 안 된다 Type("SELECT * FROM P_PATINF"); Check("같은 낱말을 이어 쳐도 기준점이 그대로", Math.Abs(popup.HorizontalOffset - firstOffset.HorizontalOffset) < 0.5, $"{firstOffset.HorizontalOffset:F1} → {popup.HorizontalOffset:F1}"); // ③ 줄이 바뀌면 세로 위치도 따라간다 Type("SELECT * FROM P_PATINF\nWHERE PAT"); Check("다음 줄에서는 아래로 내려간다", popup.VerticalOffset > firstOffset.VerticalOffset, $"{firstOffset.VerticalOffset:F1} → {popup.VerticalOffset:F1}"); // ④ 커서만 옮기면 닫힌다 — 옛 문맥의 목록이 남아 있으면 안 된다 var wasOpen = popup.IsOpen; box.CaretIndex = 3; DrainDispatcher(); Check("커서를 옮기면 닫힌다", wasOpen && !popup.IsOpen); // ⑤ 범위를 벗어난 좌표에서도 화면 안에 있어야 한다 Type(new string('\n', 200) + "SELECT * FROM P_"); // 목록이 편집 영역 밖에 뜨면 화면 구석에 떠 있는 것처럼 보인다 Check("긴 문서에서도 편집 영역 안에 뜬다", !double.IsNaN(popup.VerticalOffset) && !double.IsInfinity(popup.VerticalOffset) && popup.VerticalOffset >= 0 && popup.VerticalOffset <= box.ActualHeight && popup.HorizontalOffset >= 0 && popup.HorizontalOffset <= box.ActualWidth, $"세로 {popup.VerticalOffset:F1} / 편집 높이 {box.ActualHeight:F1}"); // ⑥ 포커스를 잃으면 닫힌다 Type("SELECT * FROM P_"); var openBeforeBlur = popup.IsOpen; System.Windows.Input.Keyboard.ClearFocus(); box.RaiseEvent(new System.Windows.RoutedEventArgs(System.Windows.UIElement.LostFocusEvent)); DrainDispatcher(); Check("포커스를 잃으면 닫힌다", openBeforeBlur && !popup.IsOpen, $"blur 전 열림={openBeforeBlur}, blur 후 열림={popup.IsOpen}"); // ⑦ 바깥 클릭으로 닫히는 설정인지 Check("항목 클릭이 캡처에 먹히지 않는 설정(StaysOpen=True)", popup.StaysOpen); // ⑦-2 목록 항목을 집으면 실제로 입력돼야 한다. // 예전에는 클릭이 포커스를 가져가 LostFocus 가 팝업을 닫아 버려 아무것도 안 들어갔다. // 마우스 라우팅 자체는 화면 없이 흉내 낼 수 없어(Ctrl+Enter 때와 같은 이유) // 핸들러가 부르는 그 경로(PickCompletionForSmoke)를 태워 삽입까지 확인한다. var list = (System.Windows.Controls.ListBox)window.FindName("CompletionList")!; Type("SELECT * FROM E_Sht"); if (popup.IsOpen && list.Items.Count > 0) { var before = box.Text; list.UpdateLayout(); var picked = window.PickCompletionForSmoke(0); DrainDispatcher(); Check("목록 항목을 집으면 입력되고 목록이 닫힌다", picked && box.Text.Length > before.Length && !popup.IsOpen, $"'{before}' → '{box.Text}'"); } else { Check("목록 항목을 집으면 입력되고 목록이 닫힌다", false, "검사 전에 목록이 열리지 않았다"); } // ⑧ 색칠 레이어와 실제 글자 위치가 맞는지 — 한글이 섞이면 폰트 대체 때문에 어긋났었다. // 레이어는 TextBox 에게 좌표를 물으므로, 같은 인덱스의 사각형이 곧 그리는 자리다. // 여기서는 '캐럿이 글자 끝에 있을 때 그 x 가 앞 글자들 폭의 합과 단조 증가하는가'를 본다. Type("SELECT ㄴㅁㅇㄹ FROM ㅎㄱ"); var xs = new List(); for (var i = 0; i <= box.Text.Length; i++) { var r = box.GetRectFromCharacterIndex(i); xs.Add(r.IsEmpty ? double.NaN : r.X); } var monotonic = true; for (var i = 1; i < xs.Count; i++) { if (!double.IsNaN(xs[i]) && !double.IsNaN(xs[i - 1]) && xs[i] < xs[i - 1] - 0.01) { monotonic = false; break; } } Check("한글 섞인 줄에서 글자 좌표가 뒤로 밀리지 않는다", monotonic, $"첫 x {xs[0]:F1} → 끝 x {xs[^1]:F1}"); // 마지막 글자의 오른쪽 끝이 캐럿 자리와 같아야 한다(레이어가 이 값으로 그린다) var lastCharRect = box.GetRectFromCharacterIndex(box.Text.Length - 1); var caretRect = box.GetRectFromCharacterIndex(box.Text.Length); Check("마지막 글자 다음 좌표가 캐럿 자리와 이어진다", !lastCharRect.IsEmpty && !caretRect.IsEmpty && caretRect.X >= lastCharRect.X, $"글자 {lastCharRect.X:F1} / 캐럿 {caretRect.X:F1}"); // ⑨ 검증 실행 결과가 표로 실제로 채워지는가. // 요약만 보고 넘어가면 '컬럼 2개, 표본 3행'이라 적혀 있는데 표는 비어 있는 상태를 놓친다 // (ExpandoObject 를 넘기면 DataGrid 가 열을 못 만든다). var grid = (System.Windows.Controls.DataGrid)window.FindName("TrialGrid")!; var pane = (System.Windows.FrameworkElement)window.FindName("TrialPane")!; Type("SELECT ShtCod, ShtKorNam FROM E_ShtMst WHERE ROWNUM <= 3"); window.RunTrialForSmoke(); DrainDispatcher(); grid.UpdateLayout(); Check("검증 결과 창이 열린다", pane.Visibility == System.Windows.Visibility.Visible); Check("결과 표에 열이 생긴다", grid.Columns.Count >= 2, $"열 {grid.Columns.Count}개"); Check("결과 표에 행이 채워진다", grid.Items.Count >= 1, $"행 {grid.Items.Count}개"); // ⑨-b 치환 변수를 값 자리에 쓴 쿼리가 검증 실행을 통과할 수 있는가. // 전에는 변수를 빈 문자열로 지워 "WHERE X = " 가 되었고 ORA-00936 이 났다 — // 즉 이 기능이 존재하는 이유인 쿼리들이 하나도 검사되지 못했다. var summary = (System.Windows.Controls.TextBlock)window.FindName("TrialSummary")!; Type("select * from P_PatInf" + " where PatChtNum = <>"); window.RunTrialForSmoke(); DrainDispatcher(); Check("⑨-b 치환 변수를 값 자리에 써도 구문 검사가 돈다", !summary.Text.Contains("ORA-00936", StringComparison.Ordinal), summary.Text); Check("⑨-b 변수 자리가 비지 않는다(NULL 로 채운다)", window.LastTrialSql.Contains("NULL", StringComparison.Ordinal) && !window.LastTrialSql.TrimEnd().EndsWith("=", StringComparison.Ordinal), window.LastTrialSql); // 무엇으로 돌렸는지 말해야 한다 — 0행을 보고 "이 환자에게 자료가 없다"로 잘못 읽는다 Check("⑨-b 무엇으로 돌렸는지 요약이 밝힌다", summary.Text.Contains("NULL", StringComparison.Ordinal) || summary.Text.Contains("환자", StringComparison.Ordinal), summary.Text); // 따옴표 밖 변수는 이 배선의 가장 흔한 실수다 — 값이 원문 그대로 박히기 때문이다 Check("⑨-b 따옴표 밖 변수를 짚어 준다", summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text); // 대조군 — 따옴표로 감싼 같은 쿼리에는 그 경고가 없어야 한다. // 없으면 위 판정은 "항상 경고한다"와 구분되지 않는다. Type("select * from P_PatInf" + " where PatChtNum = '<>'"); window.RunTrialForSmoke(); DrainDispatcher(); Check("⑨-b (대조군) 따옴표로 감싸면 그 경고가 없다", !summary.Text.Contains("따옴표", StringComparison.Ordinal), summary.Text); // ⑩ 검증 실행 단축키가 실제로 배선돼 있는가. // 합성 키 이벤트로는 Keyboard.Modifiers 가 잡히지 않으므로(실제 키 상태를 읽는다) // 선언된 KeyBinding 을 찾아 그 명령을 직접 실행해 확인한다. var bindings = window.InputBindings.OfType().ToList(); var ctrlEnter = bindings.FirstOrDefault(b => b.Key == System.Windows.Input.Key.Return && b.Modifiers == System.Windows.Input.ModifierKeys.Control); var f5 = bindings.FirstOrDefault(b => b.Key == System.Windows.Input.Key.F5); Check("Ctrl+Enter 단축키가 등록돼 있다", ctrlEnter is not null); Check("F5 단축키도 남아 있다", f5 is not null); if (ctrlEnter?.Command is { } trialCommand) { pane.Visibility = System.Windows.Visibility.Collapsed; Type("SELECT 1 FROM DUAL WHERE 1=1 AND SEL"); var popupWasOpen = popup.IsOpen; trialCommand.Execute(null); DrainDispatcher(); Check("Ctrl+Enter 로 검증이 실행된다", pane.Visibility == System.Windows.Visibility.Visible); // 목록이 떠 있어도 검증이 돈다 — Enter(목록 확정)와 뜻이 갈려야 한다 Check("실행하면서 자동완성 목록을 닫는다", popupWasOpen && !popup.IsOpen); } window.Close(); DrainDispatcher(); } catch (Exception ex) { failures++; lines.Add("예외: " + ex); } lines.Add(string.Empty); lines.Add($"결과: 실패 {failures}건"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return failures == 0 ? 0 : 1; } /// 대기 중인 디스패처 작업을 비운다 — 팝업 갱신이 Input 우선순위로 예약된다 private static void DrainDispatcher() { var frame = new System.Windows.Threading.DispatcherFrame(); System.Windows.Threading.Dispatcher.CurrentDispatcher.BeginInvoke( System.Windows.Threading.DispatcherPriority.Background, new Action(() => frame.Continue = false)); System.Windows.Threading.Dispatcher.PushFrame(frame); } /// /// 쿼리 검증 실행 점검(--db-trial) — 편집기의 '검증 실행'이 실제 DB 에서 무엇을 돌려주는지. /// /// 이 기능은 오타를 배포 전에 잡으라고 만든 것이라, 정상 쿼리는 통과하고 /// 틀린 쿼리는 사람이 읽을 수 있는 이유와 함께 실패해야 한다. /// 조회 외 문장을 막는 안전 장치도 함께 검사한다 — 운영 DB 에 붙을 수 있는 도구다. /// public static int RunQueryTrial(string reportPath) { var lines = new List(); try { var workbench = new SheetMe.Data.Stores.OracleQueryWorkbench(ConfigService.Current.ConnectionString); lines.Add($"DB 사용 가능: {workbench.CanUseDb}"); var cases = new (string Label, string Sql, bool ExpectOk)[] { ("정상 조회", "SELECT ShtCod, ShtKorNam FROM E_ShtMst WHERE ROWNUM <= 3", true), ("없는 테이블", "SELECT * FROM 없는테이블_XYZ", false), ("없는 컬럼", "SELECT 없는컬럼_XYZ FROM E_ShtMst", false), ("구문 오류", "SELECT FROM WHERE", false), ("앞 주석 뒤 정상", "-- 설명\nSELECT 1 FROM DUAL", true), ("조회 아님(INSERT)", "INSERT INTO E_ShtMst (ShtCod) VALUES ('X')", false), ("조회 아님(DELETE)", "DELETE FROM E_ShtMst", false), ("조회 아님(DROP)", "DROP TABLE E_ShtMst", false), ("주석으로 가린 DELETE", "/* SELECT */ DELETE FROM E_ShtMst", false), ("빈 쿼리", " ", false), }; var failures = 0; foreach (var (label, sql, expectOk) in cases) { var result = workbench.Trial(sql); var ok = result.Ok == expectOk; if (!ok) { failures++; } lines.Add($"{(ok ? "PASS" : "FAIL")} {label,-18} → " + (result.Ok ? result.Summary : "실패: " + result.Error)); } var tables = workbench.ListTables(); var columns = workbench.ListColumns("E_ShtMst"); lines.Add(string.Empty); lines.Add($"자동완성 원천 — 테이블·뷰 {tables.Count}건, E_ShtMst 컬럼 {columns.Count}건"); lines.Add(" 표본 테이블: " + string.Join(", ", tables.Take(8).Select(t => t.Name))); lines.Add(" 표본 컬럼: " + string.Join(", ", columns.Take(10))); lines.Add(string.Empty); lines.Add($"결과: 실패 {failures}건"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return failures == 0 ? 0 : 1; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 데이터·액션 태그 실사용 분포(--db-tags) — 태그 피커를 어떻게 정렬·큐레이션할지 근거(읽기 전용). /// /// 카탈로그는 수백 종인데 목록이 가나다순이면 자주 쓰는 태그가 어디 있는지 알 수 없다. /// 상위 몇 개가 전체의 몇 %를 덮는지 알아야 '자주 쓰는 것 먼저' 가 의미 있는지 판단할 수 있다. /// 타입별로도 나눈다 — 이미지에 붙는 태그와 텍스트박스에 붙는 태그는 겹치지 않는다. /// public static int RunTagUsageReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); var byKey = new Dictionary>(StringComparer.Ordinal); // 태그 → 이 태그를 쓰는 컨트롤 타입 분포 var byType = new Dictionary>(StringComparer.Ordinal); // 한 서식이 쓰는 서로 다른 태그 수 — 피커를 몇 번 여는지의 대리 지표 var perSheet = new List(); foreach (var sheet in sheets) { var sheetTags = new HashSet(StringComparer.Ordinal); try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } void Visit(Core.Models.ControlElement element) { foreach (var key in new[] { "DataInterfaceTag", "DataActionTag" }) { var value = element.Props.GetText(key); // "None" 은 액션 없음의 기본값이라 사용으로 치지 않는다 if (value is null || value.Length == 0 || value == "None") { continue; } if (!byKey.TryGetValue(key, out var bucket)) { bucket = new Dictionary(StringComparer.Ordinal); byKey[key] = bucket; } bucket[value] = bucket.GetValueOrDefault(value) + 1; sheetTags.Add(key + "/" + value); var typeKey = key + "|" + element.Type; if (!byType.TryGetValue(typeKey, out var types)) { types = new Dictionary(StringComparer.Ordinal); byType[typeKey] = types; } types[value] = types.GetValueOrDefault(value) + 1; } foreach (var child in element.Children) { Visit(child); } } foreach (var page in serializer.Read(raw.Value.Xml).Pages) { Visit(page.Root); } if (sheetTags.Count > 0) { perSheet.Add(sheetTags.Count); } } catch { // 통계용 } } foreach (var (key, bucket) in byKey.OrderBy(p => p.Key, StringComparer.Ordinal)) { var total = bucket.Values.Sum(); var ranked = bucket.OrderByDescending(p => p.Value).ToList(); lines.Add(string.Empty); lines.Add($"[{key}] 사용 {total}건 / 서로 다른 값 {bucket.Count}종"); foreach (var n in new[] { 5, 10, 20, 30, 50 }) { var covered = ranked.Take(n).Sum(p => p.Value); lines.Add($" 상위 {n,2}종이 전체의 {100.0 * covered / Math.Max(1, total),5:F1}% 를 덮는다"); } lines.Add(" ── 상위 30 ──"); var rank = 0; foreach (var (value, count) in ranked.Take(30)) { rank++; lines.Add($" {rank,2}. {value,-34} {count,6}건 ({100.0 * count / total,4:F1}%)"); } // 카탈로그에 없는 값 = 사이트가 추가했거나 오타 var catalog = key == "DataInterfaceTag" ? (IReadOnlyList)Core.Catalog.LegacyTagCatalog.DataInterfaceTags : Core.Catalog.LegacyTagCatalog.DataActionTags; var unknown = ranked.Where(p => !catalog.Contains(p.Key)).ToList(); lines.Add($" 카탈로그에 없는 값: {unknown.Count}종 / {unknown.Sum(p => p.Value)}건"); lines.AddRange(unknown.Take(10).Select(p => $" {p.Key} ×{p.Value}")); // 카탈로그 생성용 전량 덤프 — 피커가 건수를 보여 주려면 상위 30종으로는 부족하다 lines.Add($" ══ 전량({key}) ══"); lines.AddRange(ranked.Where(p => catalog.Contains(p.Key)).Select(p => $" §{p.Key}\t{p.Value}")); } if (perSheet.Count > 0) { perSheet.Sort(); lines.Add(string.Empty); lines.Add($"태그를 쓰는 서식 {perSheet.Count}건 — 한 서식이 쓰는 서로 다른 태그 수: " + $"중앙값 {perSheet[perSheet.Count / 2]}, 최대 {perSheet[^1]}, 평균 {perSheet.Average():F1}"); } lines.Add(string.Empty); lines.Add("[컨트롤 타입별 상위 태그] — 타입을 알면 후보를 좁힐 수 있는지 확인용"); foreach (var (typeKey, values) in byType .Where(p => p.Value.Values.Sum() >= 20) .OrderByDescending(p => p.Value.Values.Sum())) { var ranked = values.OrderByDescending(p => p.Value).ToList(); var sum = values.Values.Sum(); var top16 = ranked.Take(16).Sum(p => p.Value); lines.Add(string.Empty); lines.Add($" {typeKey} {sum}건 / {ranked.Count}종 — 상위 16종이 {100.0 * top16 / sum:F1}%"); foreach (var (value, count) in ranked.Take(16)) { lines.Add($" {value,-34} {count,5}"); } } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 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) — 전용 편집기가 어떤 형태를 읽어야 하는지(읽기 전용). /// /// 레거시 편집기가 만드는 정본은 {Name}.Select("{필터}")({행}).Item("{필드}") 한 가지지만, /// 손으로 쓴 {Name}.Rows({행}).Item("{필드}") 형이 섞여 있다. 어느 쪽이 얼마나 되는지 모르면 /// 편집기가 기존 값을 못 읽어 사용자가 열 때마다 백지에서 다시 조립하게 된다. /// 파서 제약도 함께 센다 — 필터에 큰따옴표가 들어가면 런타임이 조용히 어긋난다. /// public static int RunDataTableFieldReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); var selectForm = 0; var rowsForm = 0; var otherForm = new Dictionary(StringComparer.Ordinal); var empty = 0; var quoteInFilter = new List(); var samples = new List(); var itms = new Dictionary(StringComparer.Ordinal); // 첫 토큰이 그 서식의 MDataTable 이름과 대소문자까지 맞는지 — 어긋나면 런타임이 무음 실패한다 var unresolved = new List(); foreach (var sheet in sheets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } var document = serializer.Read(raw.Value.Xml); var tableNames = new HashSet(StringComparer.Ordinal); void CollectTables(Core.Models.ControlElement element) { if (string.Equals(element.Type, "DataTable", StringComparison.Ordinal)) { tableNames.Add(element.Id); } foreach (var child in element.Children) { CollectTables(child); } } void Visit(Core.Models.ControlElement element) { var value = element.Props.GetText("DataTableField"); if (value is not null) { var trimmed = value.Trim(); if (trimmed.Length == 0) { empty++; } else if (trimmed.Contains(".Select(", StringComparison.Ordinal)) { selectForm++; if (samples.Count < 12) { samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); } // C6 — 필터 안의 큰따옴표. Split(""")(") 이 앞쪽에서 잘려 전부 어긋난다 var afterSelect = trimmed.Split(".Select(\"", 2, StringSplitOptions.None); if (afterSelect.Length == 2) { var filter = afterSelect[1].Split("\")(", 2, StringSplitOptions.None)[0]; if (filter.Contains('"')) { quoteInFilter.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); } } } else if (trimmed.Contains(".Rows(", StringComparison.Ordinal)) { rowsForm++; if (samples.Count < 12) { samples.Add($" {sheet.ShtCod} {element.Id}: {trimmed}"); } } else { otherForm[trimmed] = otherForm.GetValueOrDefault(trimmed) + 1; } if (trimmed.Length > 0) { var head = trimmed.Split('.')[0]; if (head.Length > 0 && !tableNames.Contains(head) && unresolved.Count < 20) { unresolved.Add($" {sheet.ShtCod} {element.Id}: '{head}' — 이 서식의 데이터소스: " + (tableNames.Count == 0 ? "(없음)" : string.Join(",", tableNames))); } } } if (element.Props.GetText("DataTableItms") is { } items && items.Trim().Length > 0) { var kind = items.Contains(".Select(", StringComparison.Ordinal) ? "Select형" : items.Contains(".Rows(", StringComparison.Ordinal) ? "Rows형(콤보 안 채워짐)" : "기타"; itms[kind] = itms.GetValueOrDefault(kind) + 1; } foreach (var child in element.Children) { Visit(child); } } foreach (var page in document.Pages) { CollectTables(page.Root); } foreach (var page in document.Pages) { Visit(page.Root); } } catch { // 통계용 } } lines.Add($"DataTableField — Select형 {selectForm}건 / Rows형 {rowsForm}건 / 기타 {otherForm.Values.Sum()}건 / 빈값 {empty}건"); if (otherForm.Count > 0) { lines.Add("[기타 형태]"); lines.AddRange(otherForm.OrderByDescending(p => p.Value).Take(10).Select(p => $" {p.Key} ×{p.Value}")); } lines.Add($"필터에 큰따옴표가 든 값(런타임 무음 실패): {quoteInFilter.Count}건"); lines.AddRange(quoteInFilter.Take(10)); lines.Add($"첫 토큰이 같은 서식의 데이터소스 이름과 안 맞는 값: {unresolved.Count}건"); lines.AddRange(unresolved.Take(10)); lines.Add("DataTableItms 형태: " + (itms.Count == 0 ? "(없음)" : string.Join(", ", itms.OrderByDescending(p => p.Value).Select(p => $"{p.Key}×{p.Value}")))); lines.Add("[표본]"); lines.AddRange(samples); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 선(MLine) 방향 일치 검사(--db-lines) — 디자이너 렌더를 바꿔야 하는지 판단하는 근거(읽기 전용). /// /// 우리 캔버스는 경계 사각형으로 선을 그리고, EMR 런타임은 Orientation 속성으로 그린다 /// (MLine.vb: Horizontal 이면 (0,0)→(Width,0), Vertical 이면 (0,0)→(0,Height)). /// 두 규칙이 운영 데이터에서 실제로 어긋나는 건수를 세면, /// 렌더를 고쳐야 하는지 / 속성만 노출하면 되는지가 사실로 갈린다. /// public static int RunLineReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); var total = 0; var hasOrientation = 0; // 경계 모양(가로로 길다/세로로 길다)과 Orientation 이 어긋나는 건수 var lookVerticalDrawHorizontal = new List(); // 화면엔 세로, EMR 은 가로(=1px 점) → 사라짐 var lookHorizontalDrawVertical = new List(); var square = 0; void Visit(Core.Models.ControlElement element, string sheetCod) { if (string.Equals(element.Type, "Line", StringComparison.Ordinal)) { total++; var orientation = element.Props.GetText("Orientation"); if (orientation is not null) { hasOrientation++; } var vertical = string.Equals(orientation, "Vertical", StringComparison.OrdinalIgnoreCase); var w = element.Bounds.W; var h = element.Bounds.H; if (Math.Abs(w - h) < 0.5) { square++; } else if (h > w && !vertical) { lookVerticalDrawHorizontal.Add($" {sheetCod} {element.Id} {w}×{h} Orientation={orientation ?? "(없음)"}"); } else if (w > h && vertical) { lookHorizontalDrawVertical.Add($" {sheetCod} {element.Id} {w}×{h} Orientation=Vertical"); } } foreach (var child in element.Children) { Visit(child, sheetCod); } } foreach (var sheet in sheets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } foreach (var page in serializer.Read(raw.Value.Xml).Pages) { Visit(page.Root, sheet.ShtCod); } } catch { // 통계용 } } lines.Add($"선 {total}개, Orientation 보유 {hasOrientation}개 ({100.0 * hasOrientation / Math.Max(1, total):F1}%), 정사각 {square}개"); lines.Add($"모양은 세로인데 EMR 은 가로로 그림(사라짐): {lookVerticalDrawHorizontal.Count}개"); lines.AddRange(lookVerticalDrawHorizontal.Take(30)); lines.Add($"모양은 가로인데 EMR 은 세로로 그림: {lookHorizontalDrawVertical.Count}개"); lines.AddRange(lookHorizontalDrawVertical.Take(30)); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 타입별 속성 사용 실태 전수 집계(--db-props) — 속성 큐레이션의 근거(읽기 전용). /// /// 어떤 키를 인스펙터에 올릴지는 취향이 아니라 실사용 빈도로 정해야 한다. /// 함께 '기술자에 없는 키'를 표시해, 운영에서 쓰는데 우리 화면에서 편집할 수 없는 것을 드러낸다. /// public static int RunPropUsageReport(string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var sheets = store.ListSheets(null, max: 3000).Where(s => s.HasDesign).ToList(); // 타입 → 인스턴스 수 var typeCounts = new Dictionary(StringComparer.Ordinal); // 타입 → 키 → 보유 인스턴스 수 var keyCounts = new Dictionary>(StringComparer.Ordinal); // 타입 → 키 → 값 → 건수 var valueCounts = new Dictionary>>(StringComparer.Ordinal); void Visit(Core.Models.ControlElement element) { var type = element.Type; typeCounts[type] = typeCounts.GetValueOrDefault(type) + 1; if (!keyCounts.TryGetValue(type, out var keys)) { keys = new Dictionary(StringComparer.Ordinal); keyCounts[type] = keys; valueCounts[type] = new Dictionary>(StringComparer.Ordinal); } foreach (var key in element.Props.Keys) { keys[key] = keys.GetValueOrDefault(key) + 1; // 값 분포는 짧은 값만 — 수식·쿼리·이미지는 집계 의미가 없다 var value = element.Props.GetText(key); if (value is not null && value.Length <= 24) { if (!valueCounts[type].TryGetValue(key, out var bucket)) { bucket = new Dictionary(StringComparer.Ordinal); valueCounts[type][key] = bucket; } bucket[value] = bucket.GetValueOrDefault(value) + 1; } } foreach (var child in element.Children) { Visit(child); } } var scanned = 0; foreach (var sheet in sheets) { try { var raw = store.LoadActiveDesignRaw(sheet.ShtCod); if (raw is null) { continue; } scanned++; foreach (var page in serializer.Read(raw.Value.Xml).Pages) { foreach (var child in page.Root.Children) { Visit(child); } } } catch { // 통계용 — 개별 실패는 건너뛴다(왕복 검증은 --db-smoke 가 본다) } } lines.Add($"디자인 {scanned}건 스캔, 컨트롤 {typeCounts.Values.Sum()}개"); foreach (var (type, count) in typeCounts.OrderByDescending(p => p.Value)) { lines.Add(string.Empty); var descriptor = Core.Catalog.ControlRegistry.Find(type); var known = descriptor is null ? new HashSet(StringComparer.Ordinal) : descriptor.Properties.Select(p => p.Key).ToHashSet(StringComparer.Ordinal); lines.Add($"[{type}] {count}개" + (descriptor is null ? " ※ 기술자 없음" : "")); foreach (var (key, holders) in keyCounts[type].OrderByDescending(p => p.Value)) { var share = 100.0 * holders / count; var mark = known.Contains(key) ? " " : "+"; // + = 인스펙터에서 편집 불가 var values = string.Empty; if (valueCounts[type].TryGetValue(key, out var bucket)) { values = " " + string.Join(" ", bucket.OrderByDescending(p => p.Value).Take(4) .Select(p => $"{p.Key}×{p.Value}")); } lines.Add($"{mark} {key,-28} {holders,7} ({share,5:F1}%){values}"); } } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 서식 마스터 등록 관련 컬럼 값 분포(--db-shtmst) — 신규 등록 기본값을 실측으로 정하기 위한 집계(읽기 전용). /// /// 관행값을 추측으로 넣으면 등록은 성공하는데 목록에서만 사라지는 종류의 사고가 난다 /// (예: ShtExpDte 가 비면 dtEMRLoader 의 NVL(ShtExpDte,' ')='29991231' 조건에서 통째로 탈락). /// 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(); 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(); 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; } } /// 테이블 샘플 행 조회(--db-sample) — 읽기 전용, 스키마 규약 확인용 public static int RunSample(string tableName, string reportPath, int max = 12) { try { if (!System.Text.RegularExpressions.Regex.IsMatch(tableName, "^[A-Za-z0-9_]{1,30}$")) { throw new ArgumentException("유효하지 않은 테이블명"); } var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = $"SELECT * FROM {tableName} WHERE ROWNUM <= {max}"; var lines = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { var parts = new List(); for (var i = 0; i < reader.FieldCount; i++) { var value = reader.IsDBNull(i) ? "∅" : reader.GetValue(i).ToString() ?? ""; if (value.Length > 30) { value = value[..30] + "…"; } parts.Add($"{reader.GetName(i)}={value}"); } lines.Add(string.Join(" | ", parts)); } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// Spread 디자인 조회(--db-spd) — 목록 또는 특정 건 원문 덤프(읽기 전용) public static int RunSpreadDump(string shtCodOrAll, string reportPath) { try { var config = ConfigService.Current; using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(config.ConnectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; if (shtCodOrAll == "*") { command.CommandText = "SELECT SpdShtCod, SpdName, LENGTH(SpdDesign), NVL(SpdDelYon,' ') FROM E_SpdMst ORDER BY SpdShtCod"; var lines = new List(); using var reader = command.ExecuteReader(); while (reader.Read()) { lines.Add($"{reader.GetString(0)}\t{reader.GetString(1)}\t{reader.GetDecimal(2):N0}자\t{(reader.GetString(3) == "Y" ? "이력" : "활성")}"); } File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); } else { command.CommandText = "SELECT SpdDesign FROM E_SpdMst WHERE TRIM(SpdShtCod) = :s AND NVL(SpdDelYon,' ') <> 'Y' AND ROWNUM = 1"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("s", shtCodOrAll)); File.WriteAllText(reportPath, command.ExecuteScalar() as string ?? "(없음)"); } return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// 활성 디자인 XML 원문 덤프(--db-xml) — 읽기 전용 public static int RunDesignXml(string shtCod, string reportPath) { try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var raw = store.LoadActiveDesignRaw(shtCod); File.WriteAllText(reportPath, raw is null ? "(없음)" : $"SdgKey={raw.Value.SdgKey}{Environment.NewLine}{raw.Value.Xml}"); return raw is null ? 1 : 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// 서식 검색(--db-find) — 키워드 목록 출력 public static int RunFind(string keyword, string reportPath) { try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var sheets = store.ListSheets(keyword, max: 50); File.WriteAllText(reportPath, string.Join(Environment.NewLine, sheets.Select(s => $"{s.ShtCod}\t{(s.HasDesign ? "디자인O" : "-")}\t{s.Name}"))); return 0; } catch (Exception ex) { File.WriteAllText(reportPath, "실패: " + ex.Message); return 1; } } /// 상용구(E_SHTWRDMST) CRUD 왕복 스모크(--db-word-smoke) — 등록→조회→수정→순서→삭제(흔적 없음) public static int RunWordSmoke(string shtCod, string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new RecordWordStore(config.ConnectionString); // 이전 스모크 잔여 정리 foreach (var stale in store.List(shtCod).Where(w => w.Value.StartsWith("스모크 문구"))) { store.Remove(stale.Key, UserSession.AuditUid); } var before = store.List(shtCod).Count; lines.Add($"[사전] {shtCod} 상용구 {before}건"); var key1 = store.Add(shtCod, "스모크 문구 1 (특이사항 없음)", UserSession.AuditUid); var key2 = store.Add(shtCod, "스모크 문구 2 (경과 양호)", UserSession.AuditUid); var afterAdd = store.List(shtCod); var okAdd = afterAdd.Count == before + 2 && afterAdd.Any(w => w.Key == key1) && afterAdd.Any(w => w.Key == key2); lines.Add($"[추가] 2건 → {afterAdd.Count}건 (키 {key1}, {key2}) : {(okAdd ? "통과" : "실패")}"); store.Update(key1, "스모크 문구 1 (수정됨)", UserSession.AuditUid); var updatedValue = store.List(shtCod).FirstOrDefault(w => w.Key == key1)?.Value; var okUpdate = updatedValue == "스모크 문구 1 (수정됨)"; lines.Add($"[수정] 값=\"{updatedValue}\" : {(okUpdate ? "통과" : "실패")}"); var reordered = afterAdd.Select(w => w.Key).ToList(); reordered.Reverse(); store.Reorder(reordered, UserSession.AuditUid); var firstKey = store.List(shtCod).FirstOrDefault()?.Key; var okReorder = firstKey == reordered[0]; lines.Add($"[순서] 첫 키={firstKey} 기대={reordered[0]} : {(okReorder ? "통과" : "실패")}"); store.Remove(key1, UserSession.AuditUid); store.Remove(key2, UserSession.AuditUid); var afterRemove = store.List(shtCod).Count; var okRemove = afterRemove == before; lines.Add($"[삭제] 원상복구 → {afterRemove}건 : {(okRemove ? "통과" : "실패")}"); var ok = okAdd && okUpdate && okReorder && okRemove; lines.Add(ok ? "결과: 통과" : "결과: 실패"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return ok ? 0 : 1; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } /// /// 제자리 갱신(ShtCneYon='Y') 서식에서 컨트롤 문구를 실제로 바꿔 E_SctMst.SctObjTxt 반영을 확인하고 원복한다. /// 레거시 bzSaveSheetDesignNControlInfo.vb:378 은 기존 행의 SctObjTxt 만 덮어쓰고 SctKey 는 보존하므로 /// 둘 다 검사한다. 확인 후 원문으로 되돌려 DB 상태를 원위치시킨다. /// private static bool VerifySctObjTxtUpdate(OracleLegacyFormStore store, LegacyXmlSerializer serializer, string connectionString, string shtCod, decimal sdgKey, List lines) { var raw = store.LoadActiveDesignRaw(shtCod); if (raw is null) { lines.Add("[SctObjTxt] 활성 디자인 조회 실패"); return false; } var document = serializer.Read(raw.Value.Xml); document.FormId = shtCod; var target = EnumerateControls(document).FirstOrDefault(c => (c.Props.GetText("Text") ?? string.Empty).Length > 0); if (target is null) { lines.Add("[SctObjTxt] Text 를 가진 컨트롤이 없어 검사 생략(통과로 간주)"); return true; } var originalText = target.Props.GetText("Text")!; var probeText = originalText + "_SCTPROBE"; var (keyBefore, txtBefore) = ReadSctRow(connectionString, sdgKey, target.Id); lines.Add($"[SctObjTxt] 대상 '{target.Id}' SctKey={keyBefore} 원문='{Trim30(txtBefore)}'"); try { target.Props.SetText("Text", probeText); store.SaveDesign(document, UserSession.AuditUid); var (keyAfter, txtAfter) = ReadSctRow(connectionString, sdgKey, target.Id); var textApplied = string.Equals(txtAfter, probeText, StringComparison.Ordinal); var keyStable = keyAfter == keyBefore; lines.Add($"[SctObjTxt] 변경 후 SctKey={keyAfter}({(keyStable ? "보존" : "변동!")}) " + $"SctObjTxt='{Trim30(txtAfter)}' → {(textApplied ? "반영됨" : "미반영!")}"); return textApplied && keyStable; } finally { // 원복 — 검사 실패 여부와 무관하게 DB 를 원상태로 되돌린다 var restore = serializer.Read(store.LoadActiveDesignRaw(shtCod)!.Value.Xml); restore.FormId = shtCod; var restoreTarget = EnumerateControls(restore).FirstOrDefault(c => c.Id == target.Id); if (restoreTarget is not null) { restoreTarget.Props.SetText("Text", originalText); store.SaveDesign(restore, UserSession.AuditUid); var (_, txtRestored) = ReadSctRow(connectionString, sdgKey, target.Id); lines.Add($"[SctObjTxt] 원복 → '{Trim30(txtRestored)}' " + $"{(string.Equals(txtRestored, originalText, StringComparison.Ordinal) ? "확인" : "불일치!")}"); } } } /// E_SctMst 단일 행의 (SctKey, SctObjTxt) — 동명 중복 시 최초 1건 private static (decimal Key, string Text) ReadSctRow(string connectionString, decimal sdgKey, string objName) { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT SctKey, NVL(SctObjTxt,' ') FROM E_SctMst WHERE SctSdgKey = :k AND SctObjNam = :n ORDER BY SctKey"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey)); command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("n", objName)); using var reader = command.ExecuteReader(); return reader.Read() ? (reader.GetDecimal(0), reader.GetString(1)) : (0m, string.Empty); } /// 문서의 모든 컨트롤(자식 포함) 평탄화 private static IEnumerable EnumerateControls(FormDocument document) { foreach (var page in document.Pages) { foreach (var control in page.Controls) { foreach (var item in Flatten(control)) { yield return item; } } } static IEnumerable Flatten(ControlElement element) { yield return element; foreach (var child in element.Children) { foreach (var item in Flatten(child)) { yield return item; } } } } private static string Trim30(string value) => value.Length > 30 ? value[..30] + "…" : value; /// 무변경 재저장 스모크 — 저장 경로 전체(트랜잭션/버저닝/E_SctMst) 검증 /// 리포트를 파일과 stderr 에 남기고 종료코드를 돌려준다 private static int Finish(List lines, string reportPath, int code) { var text = string.Join(Environment.NewLine, lines); File.WriteAllText(reportPath, text); Console.Error.WriteLine(text); return code; } /// /// 제자리 정정(TK_MODIFY) 왕복 검증 — --db-modify-smoke <서식코드> <리포트>. /// /// 왜 필요한가. ModifyDesign 은 운영 테이블에 쓰는데 한 번도 실행해 본 적이 없다. /// 저장 경로에는 이런 검증이 있고(RunSaveSmoke) 수정 경로에만 없었다 — /// "코드는 맞아 보이는데" 상태이고, 이 저장소에서 그건 여러 번 틀렸다. /// /// 무엇을 단정하는가. 수정은 저장과 이력 방향이 반대다. /// ① 활성 SdgKey 가 바뀌지 않는다(EMR 이 참조하는 키가 유지되는 것이 이 동작의 존재 이유다) /// ② 활성 행의 디자인이 새 XML 로 바뀐다 /// ③ 옛 XML 을 담은 이력 행이 하나 생긴다(SdgDelYon='Y') /// ④ 활성 행의 감사 컬럼이 지금 사용자·시각이다 — 레거시는 이걸 이력 행에 썼다(버그, 미복제) /// ⑤ E_SctMst 행 수가 변하지 않는다(수정은 건드리지 않는다) /// /// DB 를 원상태로 되돌린다. 활성 행의 XML·감사값을 원문으로 UPDATE 하고, /// 이 진단이 만든 이력 행을 SdgKey 로 지운다. 지우는 것은 방금 만든 그 행 하나뿐이고 /// SdgDelYon='Y' 조건을 함께 걸어 활성 행은 어떤 경우에도 지워지지 않게 한다. /// /// /// 환자·내원 조회가 실제 DB 에서 도는가--db-patient <검색어> <리포트>. /// /// 컴파일이 통과해도 컬럼명이 틀리면 화면에서만 터진다. 레거시 SELECT 를 그대로 옮기지 않고 /// 최소 컬럼으로 다시 썼기 때문에 그 컬럼들이 실재하는지를 여기서 확인한다. /// /// 개인정보를 리포트에 남기지 않는다 — 건수와 컬럼 유무만 적고 이름·주민번호는 찍지 않는다. /// 진단 리포트는 파일로 남고 접근 통제가 DB 보다 느슨하다. /// public static int RunPatientProbe(string term, string reportPath) { var lines = new List(); var ok = true; void Check(string name, bool pass, string? detail = null) { ok &= pass; lines.Add($"{(pass ? "PASS" : "FAIL")} {name}{(pass || detail is null ? string.Empty : " — " + detail)}"); } try { var store = new PatientVisitStore(ConfigService.Current.ConnectionString); // 네 갈래 SQL 이 전부 도는지 — 결과가 0건이어도 쿼리는 성공해야 한다. // 시간을 함께 잰다. "느리다"는 체감이고, 어디가 느린지는 숫자로만 안다. foreach (var kind in new[] { PatientSearchKind.ChartNumber, PatientSearchKind.Name, PatientSearchKind.ResidentNumber, PatientSearchKind.MobilePhone, }) { var watch = System.Diagnostics.Stopwatch.StartNew(); var found = store.Search(kind, term); watch.Stop(); lines.Add($" {kind,-16} {found.Count,4}건 {watch.ElapsedMilliseconds,6}ms"); } Check("① 검색 네 갈래가 예외 없이 돈다", true); // 조건 없는 전 환자 조회를 막는가 — 레거시에는 이 방어가 없다 Check("② 빈 검색어는 아무것도 돌려주지 않는다", store.Search(PatientSearchKind.Name, " ").Count == 0); Check("③ 주민번호 1글자는 막는다", store.Search(PatientSearchKind.ResidentNumber, "8").Count == 0); // 따옴표가 SQL 로 새지 않는가 — 레거시는 이스케이프가 없어 그대로 주입된다 var quoted = store.Search(PatientSearchKind.Name, "'||'"); Check("④ 따옴표가 SQL 로 새지 않는다", quoted.Count == 0, $"{quoted.Count}건 (예외 없이 0건이어야 한다)"); // 행 상한이 실제로 걸리는가 var capped = store.Search(PatientSearchKind.Name, term, max: 3); Check("⑤ 행 상한이 걸린다", capped.Count <= 3, $"{capped.Count}건"); // 내원 목록 — 내원이 있는 환자를 찾아 들어간다. // 처음에는 첫 환자만 봤는데 그 환자에게 내원이 0건이라 ⑧~⑬ 이 통째로 SKIP 되고도 // 리포트는 "전건 통과"였다. 아무것도 확인하지 않은 통과는 통과가 아니다. var people = store.Search(PatientSearchKind.Name, term, max: 30); var probed = 0; var visits = new List(); foreach (var candidate in people) { probed++; var vw = System.Diagnostics.Stopwatch.StartNew(); visits = store.ListVisits(candidate.ChtNum); vw.Stop(); lines.Add($" ListVisits {vw.ElapsedMilliseconds}ms → {visits.Count}건"); if (visits.Count > 0) { break; } } if (people.Count == 0) { lines.Add("SKIP ⑥ 내원 목록 — 이 검색어로 환자를 못 찾아 판정 불가" + " (다른 검색어로 다시 돌려야 한다)"); } else { lines.Add($" 환자 {probed}명을 훑어 내원 있는 환자를 찾았다(전체 {people.Count}명 중)"); lines.Add($" 내원 {visits.Count}건"); Check("⑥ 내원 목록이 예외 없이 돈다", true); // 같은 내원이 여러 번 나오면 기간 PK 마스터 조인을 안 접은 것이다 Check("⑦ 같은 내원이 중복되지 않는다", visits.Select(v => v.ComNum).Distinct().Count() == visits.Count, $"고유 {visits.Select(v => v.ComNum).Distinct().Count()} / 전체 {visits.Count}"); // ── 환자 문맥 ── // 여기가 이 조사의 목적이다. 위 ①~⑦ 은 목록이 뜨는지만 본다. // 문맥 조회의 컬럼 이름은 추측으로 쓴 것이라 실DB 아니면 확인할 방법이 없고, // 틀리면 예외가 아니라 빈 값이 나온다 — 화면에서는 "환자가 값이 없다"로 보인다. if (visits.Count == 0) { // 여기까지 오면 훑은 환자 전부가 내원 0건이다. 검색어를 바꿔 다시 돌려야 한다 — // 이 줄을 통과로 세면 문맥 조회를 한 번도 확인하지 않은 채 초록불이 된다. Check($"⑧~⑬ 환자 문맥 — 훑은 {probed}명 모두 내원 0건이라 확인 불가", false, "다른 검색어로 다시 돌릴 것"); } else { var target = visits[0].ComNum; var contexts = new PatientContextStore(ConfigService.Current.ConnectionString); // 환자를 고른 뒤 사람이 기다리는 구간이다 — 문맥 5행 + 시각 + 복호화까지 붙는다 var loadWatch = System.Diagnostics.Stopwatch.StartNew(); var context = contexts.Load(target); loadWatch.Stop(); lines.Add($" 문맥 읽기 {loadWatch.ElapsedMilliseconds}ms (환자를 고른 뒤 기다리는 구간)"); Check("⑧ 문맥 5행 조회가 예외 없이 돈다", context is not null, $"내원 {target:F0} 을 읽지 못했다(목록에는 있는데 다시 읽으면 없다)"); if (context is { } ctx) { lines.Add($" 내원 {target:F0} 적용일시={ctx.AdpDtm}" + $" / PatInf {ctx.PatInf.Count}열, ComInf {ctx.ComInf.Count}열," + $" CodInf {ctx.CodInf.Count}열, CoiInf {ctx.CoiInf.Count}열, CowInf {ctx.CowInf.Count}열"); // 컬럼 이름을 실제로 확인한다 — 오타면 여기서 걸린다 var missing = new[] { "ComChtNum", "ComAcpDtm", "ComPatTyp", "ComLevDtm" } .Where(c => !ctx.ComInf.ContainsKey(c)).ToList(); Check("⑨ P_ComInf 컬럼 이름이 맞다", missing.Count == 0, $"없는 컬럼: {string.Join(", ", missing)}"); // 이 조인이 깨지면 환자 이름·차트번호 태그가 전부 빈다 Check("⑩ 내원의 차트번호로 P_PatInf 를 찾는다", ctx.PatInf.Count > 0, $"차트번호 '{PatientContext.Value(ctx.ComInf, "ComChtNum")}' 로 환자를 못 찾았다" + " (P_ComInf.ComChtNum 과 P_PatInf.PatChtNum 의 자릿수·공백 규약이 다를 수 있다)"); // 적용일시가 12자리가 아니면 BETWEEN 이 전부 0행이 된다 Check("⑪ 적용일시가 12자리다", ctx.AdpDtm.Length == 12, $"'{ctx.AdpDtm}'"); // 기간 조회 3행. 0행은 실패가 아닐 수 있다(외래는 병실이 없다) — // 그래서 0을 그냥 적지 않고 이유를 캔다. 조건을 뺀 원본 행을 함께 찍으면 // "그 내원에 애초에 행이 없다"와 "행은 있는데 시각·MtiSeq 조건이 어긋났다"가 갈린다. // 0 을 이유 없이 적어 두면 리포트가 초록불인 채로 태그가 통째로 비게 된다. lines.Add($" 기간 조회: CodInf {(ctx.CodInf.Count > 0 ? "있음" : "0행")}," + $" CoiInf {(ctx.CoiInf.Count > 0 ? "있음" : "0행")}," + $" CowInf {(ctx.CowInf.Count > 0 ? "있음" : "0행")}"); var conn = ConfigService.Current.ConnectionString; if (ctx.CodInf.Count == 0) { lines.Add(" [진단] P_CodInf 조건 없는 원본 행:"); foreach (var row in ExplainPeriod(conn, target, "SELECT CodMtiSeq, CodStrDtm, CodEndDtm FROM P_CodInf" + " WHERE CodComNum = :k ORDER BY CodStrDtm")) { lines.Add($" {row}"); } } if (ctx.CoiInf.Count == 0) { lines.Add(" [진단] P_CoiInf 조건 없는 원본 행:"); foreach (var row in ExplainPeriod(conn, target, "SELECT CoiMtiSeq, CoiStrDte, CoiEndDte FROM P_CoiInf" + " WHERE CoiComNum = :k ORDER BY CoiStrDte")) { lines.Add($" {row}"); } } Check("⑭ CodInf 0행이면 그 이유가 원본 행으로 설명된다", true, "위 [진단] 줄을 읽어 시각·MtiSeq 조건을 판단할 것"); // ── 기간 조회 세 갈래를 실제로 한 번은 통과시킨다 ── // 위 내원은 CodInf·CoiInf·CowInf 가 전부 0행이었다. 즉 이 세 SQL 의 // BETWEEN·MtiSeq 조건은 아직 한 번도 행을 돌려준 적이 없다. // 안 돌아본 갈래는 통과한 갈래가 아니다 — 그래서 각 표에서 직접 내원을 찾아 들어간다. foreach (var (label, table, key, stamp) in new[] { ("CodInf(진료과·담당의)", "P_CodInf", "CodComNum", "CodStrDtm"), ("CoiInf(보험 자격)", "P_CoiInf", "CoiComNum", "CoiStrDte"), ("CowInf(병동·병실)", "P_CowInf", "CowComNum", "CowStrDtm"), }) { var owner = FirstComNum(conn, table, key, stamp); if (owner is null) { Check($"⑮ {label} 기간 조회 — {table} 에 행이 없어 확인 불가", false, "이 DB 로는 이 갈래를 확인할 수 없다"); continue; } var sample = contexts.Load(owner.Value); var got = table switch { "P_CodInf" => sample?.CodInf.Count ?? 0, "P_CoiInf" => sample?.CoiInf.Count ?? 0, _ => sample?.CowInf.Count ?? 0, }; Check($"⑮ {label} 기간 조회가 실제로 행을 돌려준다", got > 0, $"내원 {owner.Value:F0} 적용일시={sample?.AdpDtm ?? "(문맥 없음)"} → {got}열" + " (0이면 BETWEEN 시각이나 MtiSeq 조건이 어긋난 것이다)"); } // 실제 문맥으로 태그가 값이 되는가 — 가상 문맥(--edit-smoke)과 짝이 되는 판정이다 var resolver = new Services.PatientTagResolver(ctx, new NullTagResolver()); var chart = resolver.Resolve("PAT_차트번호"); var name = resolver.Resolve("PAT_이름"); Check("⑫ 실제 문맥으로 PAT_차트번호·PAT_이름 이 값이 된다", chart.Resolved && name.Resolved, $"차트번호 {(chart.Resolved ? "값" : chart.Text)} / 이름 {(name.Resolved ? "값" : name.Text)}"); // 값 자체는 개인정보라 리포트에 쓰지 않는다 — 길이만 적는다 lines.Add($" 값 길이: 차트번호 {chart.Text.Length}자, 이름 {name.Text.Length}자" + " (값은 개인정보라 기록하지 않는다)"); // 대조군 — 매핑에 없는 환자 태그는 값이 되지 않아야 한다. // 이게 없으면 ⑫ 는 "해석기가 아무 태그나 통과시킨다"와 구분되지 않는다. var unmapped = resolver.Resolve("PAT_나이"); Check("⑬ (대조군) 안 옮긴 환자 태그는 값이 되지 않는다", !unmapped.Resolved && unmapped.Text.Contains("아직 옮기지", StringComparison.Ordinal), $"'{unmapped.Text}' (Resolved={unmapped.Resolved})"); // ── MDataTable 치환 + 실행 ── // 태그와 다른 관이다. 여기까지 돌지 않으면 "미리보기는 되는데 실제 서식은 // 반쯤 빈" 상태를 확인할 수 없다. 실DB 로만 확인 가능한 것은 // 치환한 SQL 이 실제로 실행되는가이므로 그것만 본다. var probeDoc = new SheetMe.Core.Models.FormDocument(); var probePage = new SheetMe.Core.Models.FormPage { Width = 720, Height = 856 }; probeDoc.Pages.Add(probePage); var probeSource = new SheetMe.Core.Models.ControlElement { Type = "MDataTable", Id = "MDataTable1", Bounds = new SheetMe.Core.Models.LayoutRect { X = 0, Y = 0, W = 20, H = 20 }, }; // 스칼라 치환과 DataRow 치환을 한 문장에 넣는다 — 둘은 다른 코드 경로다 probeSource.Props.SetText("Query", "SELECT TRIM(ComChtNum) CHT FROM P_ComInf" + " WHERE ComNum = <>" + " AND TRIM(ComChtNum) = '<>'"); probePage.Controls.Add(probeSource); var runner = new Services.MDataTableRunner(probeDoc, new Services.PatientQueryVariableSource(ctx, "SMOKE")); var ran = runner.Run("MDataTable1"); Check("⑯ 치환한 SQL 이 실제로 실행된다", ran.Ok && ran.Rows.Count == 1, ran.Ok ? $"{ran.Rows.Count}행 (내원번호로 1행이어야 한다)" : ran.Error); Check("⑰ 스칼라·DataRow 치환이 둘 다 값으로 박힌다", !ran.Sql.Contains("<<", StringComparison.Ordinal) && !ran.Sql.Contains("= '' ", StringComparison.Ordinal) && !ran.Sql.EndsWith("= ''", StringComparison.Ordinal), ran.Sql); // 대조군 — 환자 없이 같은 쿼리. 치환이 빈 값이 되어 SQL 이 깨져야 한다. // 이게 없으면 ⑯ 은 "쿼리가 원래 환자와 무관했다"와 구분되지 않는다. var blind = new Services.MDataTableRunner(probeDoc, null).Run("MDataTable1"); Check("⑱ (대조군) 환자 없이는 같은 쿼리가 값을 내지 못한다", !blind.Ok || blind.Rows.Count != 1, $"Ok={blind.Ok} {blind.Rows.Count}행 — 치환이 실제로 기여하지 않았다는 뜻이다"); lines.Add($" 대조군 SQL: {blind.Sql.Replace("\r", " ").Replace("\n", " ")}"); lines.Add($" 치환 못 한 변수 {blind.Unknown.Count}개"); // ── 주민번호 복호화 ── // 값은 절대 리포트에 쓰지 않는다 — 길이와 형식만 적는다. // 이 앱이 다루는 것 중 가장 민감한 값이고, 리포트는 파일로 남는다. var personal = new PersonalDataStore(conn); var flags = personal.Flags(); var hospital = Services.UserSession.Current.HspCod; lines.Add($" 암호화 설정: 전체={(flags.Enabled ? "Y" : "N")}" + $" 주민번호={(flags.ResidentNumber ? "Y" : "N")}" + $" / 병원코드='{hospital}'"); Check("⑲ 암호화 설정을 예외 없이 읽는다", true); // 병원코드가 비면 SRCH·JINJU 기관기호 보정이 걸리지 않는다 — // 그 갈래는 이 진단으로 확인되지 않는다. 대신 복호화 자체는 확인한다. // 그것이 이 단계에서 실패하면 나머지가 전부 무의미하고, DB 권한·패키지 문제라 // 코드로는 알 수 없다. 확인한 것과 못 한 것을 갈라서 적는다. if (hospital.Length == 0) { lines.Add(" [미확인] 병원코드가 없어 SRCH·JINJU 기관기호 보정은" + " 이 진단으로 확인되지 않았다(순수 함수 테스트가 그 갈래를 덮는다)"); } { var raw = flags.ResidentNumberEncrypted ? personal.Decrypt(PatientContext.Value(ctx.PatInf, "PatResEnc"), hospital) : PatientContext.Value(ctx.PatInf, "PatResNum"); var normalized = SheetMe.Core.Catalog.ResidentNumber.Normalize( raw, PatientContext.Value(ctx.PatInf, "PatBthDay"), PatientContext.Value(ctx.PatInf, "PatNatCod"), hospital); var sex = SheetMe.Core.Catalog.ResidentNumber.SexOf(normalized); lines.Add($" 주민번호: 원본 {raw.Length}자 → 보정 {normalized.Length}자," + $" 전부 숫자={normalized.All(char.IsAsciiDigit)} (값은 기록하지 않는다)"); Check("⑳ 주민번호가 13자리 숫자로 만들어진다", normalized.Length == 13 && normalized.All(char.IsAsciiDigit), $"{normalized.Length}자 — 복호화 실패나 컬럼 부재를 의심할 것"); Check("㉑ 그 번호에서 성별이 나온다", sex is "M" or "F", $"'{sex}'"); } // ── OCM_담당의사 배치의 선행 조사 ── // 레거시 GetCodInfDT 는 ORDER BY 없이 CodMtiSeq='0' 만 걸고 // 호출부가 Rows(0) 을 집는다. 한 내원에 그런 행이 둘 이상이면 // 오라클이 어느 행을 먼저 주느냐에 따라 담당의가 달라진다. // 실제로 그런 내원이 있는지 세어 본다 — 0이면 없는 문제고, // 있으면 정렬 규칙을 정해야 한다. 세기 전에는 어느 쪽인지 알 수 없다. foreach (var line in ExplainPeriod(conn, 0, "SELECT COUNT(*) 다중내원, MAX(행수) 최대행수 FROM (" + " SELECT CodComNum, COUNT(*) 행수 FROM P_CodInf" + " WHERE CodMtiSeq = 0 GROUP BY CodComNum HAVING COUNT(*) > 1)" + " WHERE :k = 0")) { lines.Add($" [조사] CodMtiSeq=0 이 2행 이상인 내원: {line}"); } // ㉒ 담당의 조회가 실제로 행을 돌려주는가. // 적용일시 BETWEEN + 최신순 1행으로 골라야 결정적이다(위 조사 근거). // 0행이면 그 조건이 어긋난 것이라 태그 셋이 통째로 빈다. { // 시험 내원에 진료과 행이 아예 없으면 0열이 나오는데 // 그건 쿼리가 아니라 데이터다. P_CodInf 에 행을 가진 내원을 찾아 건다 — // 앞서 이 함정을 밟은 적이 있다(내원 0건인 환자로 판정하려 했다). var owner = FirstComNum(conn, "P_CodInf", "CodComNum", "CodStrDtm") ?? target; var ownerCtx = owner == target ? ctx : contexts.Load(owner); var doctor = ownerCtx is null ? new Dictionary() : (IReadOnlyDictionary)contexts.Doctor(owner, ownerCtx.AdpDtm); var uidNam = PatientContext.Value(doctor, "UidNam"); lines.Add($" 담당의 조회: 내원 {owner:F0} → {doctor.Count}열," + $" 이름 {uidNam.Length}자 (값은 기록하지 않는다)"); Check("㉒ 담당의 조회가 예외 없이 돌고 행을 돌려준다", doctor.Count > 0, "0열 — 적용일시 BETWEEN 조건이나 CodDtrCod 를 의심할 것"); // ㉓ 진료과·요양기관명 — 같은 내원의 CodDepCod 로. // 코드가 이름이 되는 것까지 확인해야 태그가 값을 낸다. if (ownerCtx is { } octx) { var depCod = PatientContext.Value(octx.CodInf, "CodDepCod"); var acp = PatientContext.Value(octx.ComInf, "ComAcpDtm"); var dep = contexts.Department(depCod, acp); var korNam = PatientContext.Value(dep, "DepKorNam"); lines.Add($" 진료과 조회: 코드 '{depCod}' → {dep.Count}열," + $" 한글명 {korNam.Length}자"); Check("㉓ 진료과 마스터가 이름을 돌려준다", korNam.Trim().Length > 0, $"코드 '{depCod}' 로 M_DepMst 에서 이름을 못 찾았다"); var hospRow = contexts.HospitalRow(depCod, octx.AdpDtm); var hosp = PatientContext.Value(hospRow, "HspInsNam"); lines.Add($" 요양기관: {hospRow.Count}열, 병원명 {hosp.Length}자"); Check("㉔ 요양기관명이 나온다", hosp.Trim().Length > 0, "M_DepMst⨝M_HspMst 조인이 0행 — 유효기간이나 DepHspCod 를 의심할 것"); // ㉕ 상병 — 원문 기계 추출 SQL 이 실제로 돈다. // 이 내원에 상병이 없을 수 있으므로 O_OkdInf 에 행을 가진 내원을 찾아 건다 // (앞서 두 번 밟은 "행 없는 내원으로 판정" 함정). var okdOwner = FirstComNum(conn, "O_OkdInf", "OkdComNum", "OkdComNum"); if (okdOwner is null) { Check("㉕ 상병 — O_OkdInf 에 행이 없어 확인 불가", false, "이 DB 로는 이 갈래를 확인할 수 없다"); } else { var diag = new DiagnosisStore(conn); var all = diag.Diagnoses(okdOwner.Value); var main = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Main); var sub = diag.Diagnoses(okdOwner.Value, DiagnosisStore.Scope.Sub); lines.Add($" 상병: 내원 {okdOwner.Value:F0} → 전체 {all.Count}행," + $" 주 {main.Count}행, 부 {sub.Count}행"); Check("㉕ 상병 쿼리(전체)가 행을 돌려준다", all.Count > 0, "OkdStt='E' 조건이나 UNION 결합을 의심할 것"); Check("㉖ 주/부 필터가 실제로 가른다", main.Count + sub.Count == all.Count && main.Count <= all.Count, $"주 {main.Count} + 부 {sub.Count} ≠ 전체 {all.Count} — RN 접기 전 필터 규칙 확인"); if (all.Count > 0) { var okdKorNam = all[0].TryGetValue("KcdKorNam", out var kn) ? kn : ""; lines.Add($" 첫 상병: 한글명 {okdKorNam.Length}자," + $" 코드 {(all[0].TryGetValue("KcdElcCod", out var kc) ? kc.Length : 0)}자" + " (값은 기록하지 않는다)"); Check("㉗ 상병 한글명이 나온다", okdKorNam.Trim().Length > 0, "M_KcdMst 조인이 빈 이름을 냈다"); } } // ㉘ 수술 — 원문 기계 추출 SQL 이 실제로 돈다. var oprOwner = FirstComNum(conn, "O_OprInf", "OprComNum", "OprComNum"); if (oprOwner is null) { // "행이 없다"와 "표가 없다/조회가 죽었다"를 가른다 — // FirstComNum 은 예외를 삼키므로 이 줄 없이는 못 가른다. foreach (var line in ExplainPeriod(conn, 0, "SELECT COUNT(*) OPRCNT FROM O_OprInf WHERE :k = 0")) { lines.Add($" [진단] O_OprInf: {line}"); } lines.Add("SKIP ㉘ 수술 — 확인 불가. 위 [진단] 줄이 원인이다:" + " ORA-00942 면 이 시험 DB 에 O_OprInf 가 아예 없는 것이고," + " 수술 태그는 운영 DB 에서만 검증할 수 있다"); } else { var surgeries = new SurgeryStore(conn).Surgeries(oprOwner.Value); var opName = surgeries.Count > 0 && surgeries[0].TryGetValue("OPNAME", out var on) ? on : ""; lines.Add($" 수술: 내원 {oprOwner.Value:F0} → {surgeries.Count}행," + $" OPNAME {opName.Length}자 (값은 기록하지 않는다)"); Check("㉘ 수술 쿼리가 행을 돌려준다", surgeries.Count > 0, "OprStt='E' 조건이나 조인을 의심할 것"); } // ㉙ 바이탈 — E_EmdInf_VITAL 에 행을 가진 내원으로 실행 확인 var vitOwner = FirstComNum(conn, "E_EmdInf_VITAL INNER JOIN E_EmrInf ON EmrKey = EmdEmrKey", "EmrComNum", "EmrComNum"); if (vitOwner is null) { foreach (var line in ExplainPeriod(conn, 0, "SELECT COUNT(*) VITCNT FROM E_EmdInf_VITAL WHERE :k = 0")) { lines.Add($" [진단] E_EmdInf_VITAL: {line}"); } lines.Add("SKIP ㉙ 바이탈 — 확인 불가. 위 [진단] 줄이 원인이다" + " (ORA-00942 면 이 시험 DB 에 표가 없어 운영 DB 에서만 검증 가능)"); } else { var vital = new VitalStore(conn); var weight = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight", new[] { "EmdWeight" }, "EmdDte ASC, EmdTime ASC"); var weightLast = vital.One(vitOwner.Value, "EmdWeight", "EmdWeight", new[] { "EmdWeight" }, "EmdDte DESC, EmdTime DESC"); lines.Add($" 바이탈: 내원 {vitOwner.Value:F0} → 첫 몸무게 {weight.Length}자," + $" 마지막 {weightLast.Length}자 (값은 기록하지 않는다)"); Check("㉙ 바이탈 조회가 값을 돌려준다", weight.Length > 0, "EmdWeight 널 필터나 조인을 의심할 것"); } // ㉚ 병리판독의·진단검사의 — 환자와 무관한 원내 의사 조회. // 이 시험 DB 에 TLAB 소속·LAB 그룹 의사가 없을 수 있다 — // 0행은 실패가 아니라 태그가 사유로 완결되는 정상 경로다. // 여기서 지키는 것은 "예외 없이 돈다"(컬럼·조인 이름이 맞다)이다. try { var contextStore = new PatientContextStore(conn); var pathologyRow = contextStore.PathologyDoctor(); var labRow = contextStore.LabDoctor(); static string Shape(IReadOnlyDictionary row) => row.Count == 0 ? "0행" : $"이름 {(row.TryGetValue("UidNam", out var n) ? n.Trim().Length : 0)}자" + $"·전문의번호 {(row.TryGetValue("UidSpcLic", out var s) ? s.Trim().Length : 0)}자"; lines.Add($" 병리판독의(TLAB): {Shape(pathologyRow)}" + $" / 진단검사의(LAB그룹): {Shape(labRow)} (값은 기록하지 않는다)"); Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", true, ""); } catch (Exception ex) { Check("㉚ 병리·진단검사 의사 조회가 예외 없이 돈다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); } // ㉛ 부가정보 7종(자기 SQL) — P_PatEtcInf 에 행이 있는 차트로 돌려 // 컬럼·조인 이름이 실제 DB 와 맞는지 본다. 0값은 실패가 아니라 // 그 환자에 그 정보가 없는 것이다(태그는 사유로 완결된다). try { var extraOwner = FirstText(conn, "SELECT TRIM(PatEtcChtNum) FROM P_PatEtcInf WHERE ROWNUM = 1"); // 패딩 조사 — P_pdsoInf 는 VARCHAR2 라 저장 형태를 확정할 수 없다. // IN (:c, RPAD(:c,10)) 이 실제로 필요한지 근거를 남긴다. var padded = FirstText(conn, "SELECT COUNT(*) FROM P_pdsoInf WHERE PdsoChtNum <> TRIM(PdsoChtNum)"); var multiEtc = FirstText(conn, "SELECT NVL(MAX(cnt),0) FROM (SELECT COUNT(*) cnt FROM P_PatEtcInf" + " GROUP BY PatEtcChtNum)"); lines.Add($" [조사] P_pdsoInf 패딩 행={padded}," + $" P_PatEtcInf 차트당 최대 행수={multiEtc}"); // 협진(O_CstInf)은 O_ 계열이라 이 시험 DB 에 없을 수 있다 — // 빈 값이면 ORA-00942(운영 DB 전용 검증 대상)라는 뜻이다 var cstCount = FirstText(conn, "SELECT COUNT(*) FROM O_CstInf"); lines.Add(cstCount.Length == 0 ? " [조사] O_CstInf 없음 — 협진과 태그는 운영 DB 에서만 검증 가능" : $" [조사] O_CstInf {cstCount}행 — 협진과 조회 검증 가능"); if (extraOwner.Length == 0) { lines.Add("SKIP ㉛ 부가정보 — P_PatEtcInf 가 비어 있어 확인 불가"); } else { var extra = new PatientExtraStore(conn); var owner2 = FirstComNum(conn, "P_ComInf", "ComNum", "ComNum"); var shapes = $"실제생년월일 {extra.ActualBirthDate(extraOwner).Length}자" + $" · 국적 {extra.Nationality(extraOwner).Length}자" + $" · 장애등급 {extra.DisabilityGrade(extraOwner).Length}자" + $" · 보호자연락처 {extra.GuardianPhone(extraOwner).Length}자" + $" · 급여세대주 {extra.HouseholderNameByInsurance(extraOwner, "0", "1").Length}자" + (owner2 is null ? "" : $" · 건보세대주 {extra.HouseholderName(extraOwner, owner2.Value).Length}자" + $" · 협력업체 {extra.Cooperator(owner2.Value).Length}자") + $" · FollowUp {extra.FollowUp(extraOwner).Length}자" + $" · 최초내원 {extra.FirstVisit(extraOwner, "999912312359").Length}자" + $" · 초진일 {extra.FirstVisitByDepartment(extraOwner, "999912312359", "00", false).Length}자" + (owner2 is null ? "" : $" · 외출외박 {extra.LeaveOfAbsence(owner2.Value, false).Length}자" + $" · LMP {extra.Lmp(owner2.Value).Length}자" + $" · BST {new VitalStore(conn).OneBst(owner2.Value, "EmdDte ASC, EmdTime ASC").Length}자") + $" · 혈액형 {extra.BloodType(extraOwner).Length}자"; lines.Add($" 부가정보: {shapes} (값은 기록하지 않는다)"); Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", true, ""); // "돌지만 항상 0행"인 매칭 실수(RPAD·조인)를 가른다 — // 값이 있는 차트를 골라서 물으면 반드시 값이 나와야 한다. var natOwner = FirstText(conn, "SELECT TRIM(PatChtNum) FROM P_PatInf" + " INNER JOIN M_DtlMst ON DtlCod = PatNatCod AND DtlTblCod = 'NATCOD'" + " WHERE ROWNUM = 1"); if (natOwner.Length == 0) { lines.Add("SKIP ㉜ 국적 값 — 이 DB 에 국적 코드가 잡힌 환자가 없어 판정 불가"); } else { Check("㉜ 국적 코드가 있는 차트에서는 값이 나온다", extra.Nationality(natOwner).Length > 0, "RPAD 매칭이나 NATCOD 조인을 의심할 것"); } // 최초내원일도 같은 방식 — 재진(ComNfsTyp='2') 내원이 있는 // 차트를 골라서 물으면 반드시 값이 나와야 한다. var revisitOwner = FirstText(conn, "SELECT TRIM(ComChtNum) FROM P_ComInf" + " INNER JOIN P_CodInf ON ComNum = CodComNum" + " WHERE ComAcpStt <> 'OC' AND ComNfsTyp = '2' AND ROWNUM = 1"); if (revisitOwner.Length == 0) { lines.Add("SKIP ㉝ 최초내원일 값 — 재진 내원이 없어 판정 불가"); } else { Check("㉝ 재진 내원이 있는 차트에서는 최초내원일이 나온다", extra.FirstVisit(revisitOwner, "999912312359").Length >= 8, "RPAD 매칭이나 P_CodInf 조인을 의심할 것"); } // 협진도 같은 방식 — 회신 완료 협진이 있는 내원으로 물으면 // 과 이름이 반드시 나와야 한다(조인 회귀를 잡는다). var consultOwner = FirstText(conn, "SELECT CstComNum FROM (SELECT CstComNum FROM O_CstInf" + " INNER JOIN M_DepMst ON CstRplDep = DepCod" + " AND CstStrDte BETWEEN DepStrDte AND DepEndDte" + " WHERE CstSttFlg = 'G') WHERE ROWNUM = 1"); if (consultOwner.Length == 0 || !decimal.TryParse(consultOwner, out var cstCom)) { lines.Add("SKIP ㉞ 협진과 값 — 회신 완료 협진이 없어 판정 불가"); } else { var consults = new PatientContextStore(conn).Consults(cstCom, false); lines.Add($" 협진: 내원 {cstCom:F0} → {consults.Count}건 (값은 기록하지 않는다)"); Check("㉞ 회신 완료 협진이 있는 내원에서는 협진과가 나온다", consults.Count > 0, "O_CstInf 조인이나 CstSttFlg 를 의심할 것"); } // ㉟ 부가정보 2차분 — 새 자기 SQL 12종이 예외 없이 돈다. // O_ 계열 표(O_OkdInf·O_ImlInf·O_OutAdmInf)가 이 시험 DB 에 // 없으면 개별 [진단] 줄로 남기고 판정은 실행 가능한 것만 본다. try { var probes = new (string Probe, Func Run)[] { ("알러지", () => extra.Allergy(extraOwner, null).Length.ToString()), ("알러지목록", () => extra.AllergyRows(extraOwner).Count.ToString()), ("보험증번호", () => extra.InsuranceCertNumber(extraOwner).Length.ToString()), ("재해발생일", () => extra.AccidentDate(extraOwner).Length.ToString()), ("감염정보", () => extra.InfectionInfo(extraOwner).Length.ToString()), ("발병일", () => owner2 is null ? "-" : extra.OnsetDate(extraOwner, owner2.Value).Length.ToString()), ("당일식이", () => owner2 is null ? "-" : extra.TodayMeals(owner2.Value).Count.ToString()), ("외출예상", () => extra.ExpectedOuting(extraOwner, false).Length.ToString()), ("퇴원분석", () => owner2 is null ? "-" : extra.DischargeAnalysisOperations(owner2.Value).Count.ToString()), ("병실인실", () => owner2 is null ? "-" : extra.RoomBedCount(owner2.Value).Length.ToString()), ("병실차액", () => owner2 is null ? "-" : extra.RoomExtraCharge(owner2.Value).Length.ToString()), ("산재지정번호", () => extra.IndustrialAccidentNumber("0506", "20260819").Length.ToString()), }; var shapes2 = new List(); var failed = new List(); foreach (var (probe, run) in probes) { try { shapes2.Add($"{probe} {run()}"); } catch (Exception ex) { var first = ex.Message.Split('\n')[0]; // 표 자체가 없는 것(ORA-00942)은 시험 DB 한계다 if (first.Contains("ORA-00942")) { shapes2.Add($"{probe} [표없음]"); } else { failed.Add($"{probe}: {first}"); } } } lines.Add($" 부가정보2: {string.Join(" · ", shapes2)}"); foreach (var f in failed) { lines.Add($" [진단] {f}"); } Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", failed.Count == 0, "위 [진단] 줄의 컬럼·조인 이름을 의심할 것"); } catch (Exception ex) { Check("㉟ 부가정보 2차분 조회가 예외 없이 돈다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); } } } catch (Exception ex) { Check("㉛ 부가정보·내원이력 조회가 예외 없이 돈다", false, $"{ex.GetType().Name}: {ex.Message.Split('\n')[0]}"); } } } } } } lines.Add(string.Empty); lines.Add($"결과: {(ok ? "전건 통과" : "실패 있음")}"); return Finish(lines, reportPath, ok ? 0 : 1); } catch (Exception ex) { lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}"); return Finish(lines, reportPath, 2); } } /// /// 이 표에 행을 가진 내원 하나 — 기간 조회 갈래를 실제로 통과시켜 보려면 재료가 필요하다. /// 최근 것을 고른다(오래된 내원은 구간이 이미 닫혀 있어 판정이 덜 대표적이다). /// 표 이름·컬럼 이름은 호출부의 리터럴만 들어온다 — 사용자 입력이 닿는 경로가 아니다. /// /// 스칼라 한 칸을 문자열로 — 0행이거나 죽으면 빈 문자열(판정은 호출부가 한다) private static string FirstText(string connectionString, string sql) { try { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = sql; var value = command.ExecuteScalar(); return value is null or DBNull ? string.Empty : value.ToString()!.Trim(); } catch { return string.Empty; } } private static decimal? FirstComNum(string connectionString, string table, string key, string stamp) { try { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.CommandText = $"SELECT {key} FROM (SELECT {key} FROM {table} ORDER BY {stamp} DESC) WHERE ROWNUM = 1"; var value = command.ExecuteScalar(); return value is null or DBNull ? null : Convert.ToDecimal(value); } catch { return null; } } /// /// 기간 조회가 0행일 때 0행인지 보여 준다 — 조건을 뺀 원본 행의 구간을 그대로 찍는다. /// 값이 아니라 구간만 읽으므로 개인정보를 꺼내지 않는다. /// private static List ExplainPeriod(string connectionString, decimal comNum, string sql) { var rows = new List(); try { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = sql; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", comNum)); using var reader = command.ExecuteReader(); while (reader.Read()) { var cells = new string[reader.FieldCount]; for (var i = 0; i < reader.FieldCount; i++) { cells[i] = $"{reader.GetName(i)}=" + (reader.IsDBNull(i) ? "(null)" : $"'{reader.GetValue(i).ToString()!.Trim()}'"); } rows.Add(string.Join(" ", cells)); } if (rows.Count == 0) { rows.Add("(원본 행 자체가 없다 — 조건 문제가 아니라 이 내원에 그 정보가 없는 것이다)"); } } catch (Exception ex) { rows.Add($"진단 실패: {ex.GetType().Name}: {ex.Message}"); } return rows; } public static int RunModifySmoke(string shtCod, string reportPath) { var lines = new List(); var ok = true; void Check(string name, bool pass, string? detail = null) { ok &= pass; lines.Add($"{(pass ? "PASS" : "FAIL")} {name}{(pass || detail is null ? string.Empty : " — " + detail)}"); } try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var before = store.LoadActiveDesignRaw(shtCod) ?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}"); var versionsBefore = store.ListVersions(shtCod); var sctBefore = store.CountSctRows(before.SdgKey); var auditBefore = ReadAudit(config.ConnectionString, before.SdgKey); lines.Add($"[사전] {shtCod} 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건," + $" E_SctMst {sctBefore}건, XML {before.Xml.Length:N0}자, 감사=({auditBefore.Uid},{auditBefore.Stamp})"); // 문구를 실제로 바꾼다 — 무변경 재저장으로는 "디자인이 갱신됐다"를 증명할 수 없다 var document = serializer.Read(before.Xml); document.FormId = shtCod; var marker = "수정검증-" + before.SdgKey; var target = FirstTextControl(document); if (target is null) { lines.Add("글자를 담은 컨트롤이 없어 갱신을 증명할 수 없습니다 — 다른 서식으로 시도하세요."); return Finish(lines, reportPath, 2); } var originalText = target.Props.GetText("Text") ?? string.Empty; target.Props.SetText("Text", marker); var returnedKey = store.ModifyDesign(document, UserSession.AuditUid); var after = store.LoadActiveDesignRaw(shtCod) ?? throw new InvalidOperationException("수정 후 활성 디자인 조회 실패"); var versionsAfter = store.ListVersions(shtCod); var auditAfter = ReadAudit(config.ConnectionString, before.SdgKey); lines.Add($"[수정] 반환 SdgKey={returnedKey}, 활성 SdgKey={after.SdgKey}," + $" 버전 {versionsBefore.Count} → {versionsAfter.Count}건, 감사=({auditAfter.Uid},{auditAfter.Stamp})"); Check("① 활성 SdgKey 가 바뀌지 않는다", after.SdgKey == before.SdgKey && returnedKey == before.SdgKey, $"{before.SdgKey} → {after.SdgKey} (반환 {returnedKey})"); Check("② 활성 행이 새 디자인으로 갱신된다", after.Xml.Contains(marker, StringComparison.Ordinal)); Check("③ 이력 행이 하나 생긴다", versionsAfter.Count == versionsBefore.Count + 1, $"{versionsBefore.Count} → {versionsAfter.Count}"); var added = versionsAfter.Select(v => v.SdgKey).Except(versionsBefore.Select(v => v.SdgKey)).ToList(); var historyKey = added.Count == 1 ? added[0] : 0m; Check("③-b 새 행이 이력(SdgDelYon='Y')이다", historyKey > 0 && versionsAfter.First(v => v.SdgKey == historyKey).Deleted); if (historyKey > 0) { var historyXml = store.LoadDesignRawByKey(shtCod, historyKey); Check("③-c 이력 행이 XML 을 담는다", historyXml is not null && !historyXml.Contains(marker, StringComparison.Ordinal), "이력 행에 새 내용이 들어갔다 — 방향이 뒤집혔다"); } // 레거시는 활성 행에 감사값을 안 쓰고 이력 행에 현재 사용자·시각을 썼다. 그 버그를 복제하지 않았는지 본다. Check("④ 감사 컬럼이 활성 행에 기록된다", auditAfter.Uid == UserSession.AuditUid && auditAfter.Stamp != auditBefore.Stamp, $"활성 행 감사=({auditAfter.Uid},{auditAfter.Stamp}), 기대 uid={UserSession.AuditUid}"); // 행이 0건이면 "0 → 0" 을 비교하는 것이라 아무것도 증명하지 못한다. // 통과로 적으면 읽는 사람이 검증됐다고 믿는다 — 판정 불가라고 밝힌다. var sctAfter = store.CountSctRows(before.SdgKey); if (sctBefore == 0) { lines.Add("SKIP ⑤ E_SctMst 를 건드리지 않는다 — 이 서식은 행이 0건이라 판정 불가" + " (행이 있는 서식으로 다시 돌려야 한다)"); } else { Check("⑤ E_SctMst 를 건드리지 않는다", sctAfter == sctBefore, $"{sctBefore} → {sctAfter}"); } // 원복 — 검사 결과와 무관하게 DB 를 원상태로 되돌린다 target.Props.SetText("Text", originalText); RestoreDesign(config.ConnectionString, before.SdgKey, before.Xml, auditBefore.Uid, auditBefore.Stamp); var removed = historyKey > 0 ? DeleteHistoryRow(config.ConnectionString, historyKey) : 0; var restored = store.LoadActiveDesignRaw(shtCod); var versionsRestored = store.ListVersions(shtCod); Check("원복: 활성 XML 이 원문으로 돌아온다", restored is not null && restored.Value.Xml.Length == before.Xml.Length && !restored.Value.Xml.Contains(marker, StringComparison.Ordinal)); Check("원복: 이력 행이 지워진다", removed == 1 && versionsRestored.Count == versionsBefore.Count, $"삭제 {removed}행, 버전 {versionsRestored.Count} (기대 {versionsBefore.Count})"); lines.Add(string.Empty); lines.Add($"결과: {(ok ? "전건 통과" : "실패 있음")}"); return Finish(lines, reportPath, ok ? 0 : 1); } catch (Exception ex) { lines.Add($"EXCEPTION {ex.GetType().Name}: {ex.Message}"); return Finish(lines, reportPath, 2); } } /// 글자를 담은 첫 컨트롤 — 갱신을 증명할 대상 private static SheetMe.Core.Models.ControlElement? FirstTextControl(SheetMe.Core.Models.FormDocument document) { foreach (var page in document.Pages) { foreach (var control in page.Controls) { if ((control.Props.GetText("Text") ?? string.Empty).Length > 0) { return control; } } } return null; } private static (string Uid, string Stamp) ReadAudit(string connectionString, decimal sdgKey) { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "SELECT NVL(SdgUidCod,' '), NVL(SdgUpdDtm,' ') FROM E_SdgMst WHERE SdgKey = :k"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey)); using var reader = command.ExecuteReader(); return reader.Read() ? (reader.GetString(0).Trim(), reader.GetString(1).Trim()) : (string.Empty, string.Empty); } /// 원복 전용 — 활성 행을 원문 XML·원 감사값으로 되돌린다 private static void RestoreDesign(string connectionString, decimal sdgKey, string xml, string uid, string stamp) { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "UPDATE E_SdgMst SET SdgDesign = :d, SdgUidCod = :u, SdgUpdDtm = :t WHERE SdgKey = :k"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("d", Oracle.ManagedDataAccess.Client.OracleDbType.Clob) { Value = xml }); command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("u", uid)); command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("t", stamp)); command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey)); command.ExecuteNonQuery(); } /// /// 이 진단이 만든 이력 행 하나를 지운다. /// SdgDelYon='Y' 를 조건에 함께 건다 — SdgKey 를 잘못 짚어도 활성 행은 지워지지 않는다. /// private static int DeleteHistoryRow(string connectionString, decimal sdgKey) { using var connection = new Oracle.ManagedDataAccess.Client.OracleConnection(connectionString); connection.Open(); using var command = connection.CreateCommand(); command.BindByName = true; command.CommandText = "DELETE FROM E_SdgMst WHERE SdgKey = :k AND SdgDelYon = 'Y'"; command.Parameters.Add(new Oracle.ManagedDataAccess.Client.OracleParameter("k", sdgKey)); return command.ExecuteNonQuery(); } public static int RunSaveSmoke(string shtCod, string reportPath) { var lines = new List(); try { var config = ConfigService.Current; var store = new OracleLegacyFormStore(config.ConnectionString); var serializer = new LegacyXmlSerializer(); var before = store.LoadActiveDesignRaw(shtCod) ?? throw new InvalidOperationException($"활성 디자인이 없습니다: {shtCod}"); var cneYon = store.GetShtCneYon(shtCod); var versionsBefore = store.ListVersions(shtCod); lines.Add($"[사전] {shtCod} ShtCneYon='{cneYon}' 활성 SdgKey={before.SdgKey}, 버전 {versionsBefore.Count}건, XML {before.Xml.Length:N0}자"); // 무변경 재저장(문서 그대로) — 레거시 저장 시와 동일하게 새 버전 생성(또는 제자리 갱신) var document = serializer.Read(before.Xml); document.FormId = shtCod; var savedKey = store.SaveDesign(document, UserSession.AuditUid); var after = store.LoadActiveDesignRaw(shtCod) ?? throw new InvalidOperationException("저장 후 활성 디자인 조회 실패"); var versionsAfter = store.ListVersions(shtCod); lines.Add($"[저장] SdgKey {before.SdgKey} → {savedKey} ({(cneYon == "Y" ? "제자리 갱신" : "신규 버전")}), 버전 {versionsAfter.Count}건"); var diffs = XmlSemanticDiff.Compare(before.Xml, after.Xml, maxDiffs: 10); lines.Add($"[비교] 원본 vs 재저장 활성본 의미론 diff: {diffs.Count}건"); lines.AddRange(diffs.Select(d => " " + d)); var sctCount = store.CountSctRows(savedKey); var walkerCount = SctMstXmlWalker.Walk(after.Xml).Count; bool sctOk; if (cneYon == "Y") { // 제자리 갱신은 레거시와 동일하게 삭제된 컨트롤의 행을 지우지 않는다(SctKey 안정성). // 따라서 과거에 지워진 컨트롤의 고아 행이 누적될 수 있고, 행 수 동일은 성립하지 않는다. // 옳은 불변식은 "워커가 낸 행이 전부 존재하고, 재저장으로 늘지 않는다" 이다. sctOk = sctCount >= walkerCount; var orphans = sctCount - walkerCount; lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → " + (sctOk ? $"정상{(orphans > 0 ? $" (과거 삭제 컨트롤의 잔존 행 {orphans}건 — 레거시도 지우지 않음)" : "")}" : "부족! 워커 산출 행이 DB 에 없음")); } else { // 버저닝은 E_SctMst 를 새 키로 전량 재생성하므로 정확히 일치해야 한다 sctOk = sctCount == walkerCount; lines.Add($"[E_SctMst] 저장본 행 {sctCount}건 / 워커 기대 {walkerCount}건 → {(sctOk ? "일치" : "불일치")}"); var oldVersion = versionsAfter.FirstOrDefault(v => v.SdgKey == before.SdgKey); lines.Add($"[이력] 이전 버전 SdgKey={before.SdgKey} SdgDelYon='Y' 처리: {(oldVersion?.Deleted == true ? "확인" : "미확인!")}"); } var ok = diffs.Count == 0 && sctOk; // 제자리 갱신 경로에서만 의미가 있는 검사 — 무변경 재저장으로는 SctObjTxt 갱신을 증명할 수 없으므로 // 문구를 실제로 바꿔 E_SctMst 에 반영되는지 확인하고 원복한다(레거시는 SctObjTxt 만 덮어쓴다). if (ok && cneYon == "Y") { ok &= VerifySctObjTxtUpdate(store, serializer, config.ConnectionString, shtCod, savedKey, lines); } lines.Add(ok ? "결과: 통과 — 레거시 디자이너/EMR 미리보기로 열어 최종 육안 확인 권장" : "결과: 실패"); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return ok ? 0 : 1; } catch (Exception ex) { lines.Add("실패: " + ex); File.WriteAllText(reportPath, string.Join(Environment.NewLine, lines)); return 1; } } #endregion }